URL encoding (percent-encoding) turns unsafe characters into %XX. Here is %20 vs +, UTF-8, double encoding, and why validators must decode once first.
Password entropy measures how hard a secret is to guess, in bits. Here is the formula, why Password1! fails it, and how to generate something strong.
9 min read
A hash is a fixed-length fingerprint of data. Here is how MD5 and SHA-256 differ, why fast hashes are wrong for passwords, and what HMAC adds.
10 min read
URL encoding, also called percent-encoding, turns characters that aren't safe in a URL into a % followed by two hex digits. A space becomes %20. A literal percent sign becomes %25. Browsers, APIs and servers all rely on it, and so do a lot of the log lines you'll read during a pentest.
Need to encode or decode one now? The URL Encoder & Decoder shows a single decode and a double decode side by side. Use test strings, not real tokens.
The outputs below were captured on 14 October 2026 (IST) on a Linux box with Python 3.13.5 and Node.js v20.19.2.
RFC 3986 §2.1 defines it as one percent sign plus two hex digits per byte:
pct-encoded = "%" HEXDIG HEXDIG
The space character is byte 0x20, so it becomes %20. The percent character itself is data that has to be encoded, so it becomes %25. Uppercase and lowercase hex digits are equivalent; the RFC says producers "should use uppercase hexadecimal digits for all percent-encodings."
python3 -c "
from urllib.parse import quote
print(quote(' '), quote('%'), quote('café'))
print('é as UTF-8 bytes:', 'é'.encode('utf-8').hex(' '))
"
%20 %25 caf%C3%A9
é as UTF-8 bytes: c3 a9
café is the UTF-8 walkthrough in one line. The letter é is the two bytes c3 a9, so it becomes %C3%A9. Encoding works on bytes, not on characters.
RFC 3986 splits characters into two groups:
- . _ ~): these never need encoding. Encoding and leaving them alone are equivalent.: / ? # [ ] @ ! $ & ' ( ) * + , ; =): these are the delimiters of a URL. Encode them when they are data, leave them alone when they are structure.MDN's percent-encoding glossary has a readable table of the same set. The rule of thumb: if a character is part of the value and not part of the URL's grammar, encode it.
This is the most common confusion. %20 always means space. A + means space only in application/x-www-form-urlencoded data: HTML form bodies and many query strings built the same way. In a path, + is just a plus.
The WHATWG URL Standard says URLSearchParams "will encode U+0020 SPACE as U+002B (+)". Python mirrors the split with quote and quote_plus:
python3 -c "
from urllib.parse import quote, quote_plus
s = 'name=John Doe & role=admin'
print(quote(s, safe=''))
print(quote_plus(s))
print(quote('/docs/a b'))
"
name%3DJohn%20Doe%20%26%20role%3Dadmin
name%3DJohn+Doe+%26+role%3Dadmin
/docs/a%20b
That third line is a path: space becomes %20, and Python's quote keeps / by default (safe='/'). Decode with the matching function. unquote leaves a + alone; unquote_plus turns it into a space:
'a+b c' 'a b c'
Paste the first two lines into the URL decoder and you'll see the same split.
The usual bug is string-gluing a raw value into a query:
python3 -c "
from urllib.parse import urlencode
q = 'fish & chips'
print('https://example.test/search?q=' + q)
print('https://example.test/search?' + urlencode({'q': q}))
"
https://example.test/search?q=fish & chips
https://example.test/search?q=fish+%26+chips
In the first URL the & starts a second parameter. In the second, it's data.
JavaScript has the same split. encodeURI leaves URL structure alone. encodeURIComponent encodes almost everything, which is what you want for a single query value. MDN lists what each one keeps:
node -e "
const s = 'name=John Doe & role=admin';
console.log(encodeURIComponent(s));
console.log(encodeURI('https://example.test/a b?q=x&y=1'));
console.log(encodeURIComponent('https://example.test/a b?q=x&y=1'));
"
name%3DJohn%20Doe%20%26%20role%3Dadmin
https://example.test/a%20b?q=x&y=1
https%3A%2F%2Fexample.test%2Fa%20b%3Fq%3Dx%26y%3D1
encodeURI is fine for a whole URL that already has its structure. For a value going into ?q=, use encodeURIComponent (or Python's urlencode / quote with an empty safe).
Encode a space once and you get %20. Encode that string again and the % becomes %25, so you get %2520:
python3 -c "
from urllib.parse import quote, unquote
once = quote(' ')
twice = quote(once)
print(once, twice)
print(repr(unquote(twice)), repr(unquote(unquote(twice))))
"
%20 %2520
'%20' ' '
One decode peels off one layer. Two decodes get you back to the space. RFC 3986 is blunt about this: "Implementations must not percent-encode or decode the same string more than once." Double encoding usually means someone encoded an already-encoded value, or a proxy and an app each encoded it. The tool's double-decode column is there so you can spot that in a log line.
Filters that look at the raw request can miss what the app will eventually see. Here's a tiny, defensive check that decodes once, then validates:
python3 -c "
from urllib.parse import unquote
def check(raw):
value = unquote(raw)
if '%' in value:
return 'reject: still encoded after one decode'
if '..' in value or '/' in value:
return 'reject: path characters in a filename'
return 'ok: ' + value
for raw in ['report%20q3.pdf', '%2e%2e%2fsecret', '%252e%252e%252fsecret']:
print(raw, '->', check(raw))
"
report%20q3.pdf -> ok: report q3.pdf
%2e%2e%2fsecret -> reject: path characters in a filename
%252e%252e%252fsecret -> reject: still encoded after one decode
%2e is a dot and %2f is a slash, so %2e%2e%2f is ../ after one decode. %252e%252e%252f is still encoded after one decode, which is why the check rejects it instead of decoding again. Same idea applies to carriage return / line feed sequences that show up as %0D%0A in header-injection logs: look at the decoded value.
The OWASP Input Validation Cheat Sheet puts it as: "Decode according to the protocol before checking field rules. Validate the representation that will actually be used, and avoid decoding it again downstream." Canonicalise once, then validate. Reject or log anything that still looks encoded. Don't decode in two layers with two different rules. For real file paths, resolve the decoded path and confirm it still sits inside the base directory; a character blocklist alone isn't enough. If you build normalisation into a WAF, the WAF rule compiler article walks through matching against the normalised form.
| Job | Encoding | Tool |
|---|---|---|
| Put data in a URL | Percent-encoding | URL decoder |
| Put data in HTML output | HTML entities | HTML entity tool |
| Put binary in text (tokens, digests) | Base64 / base64url | Base64 tool |
| Try several at once | — | Encoding Workbench |
URL encoding is not HTML entity encoding. The first belongs in URLs; the second belongs in HTML so a browser doesn't treat the data as markup. Base64 is a third job: packing binary into text. JWTs use base64url, which is Base64 with a URL-safe alphabet, not percent-encoding.
It's a space. Byte 0x20, written as two hex digits after a %.
No. Anyone can decode it. It only makes a string safe to put in a URL.
Both decode the same. RFC 3986 prefers uppercase (%C3%A9 over %c3%a9).
Something encoded an already-encoded value. One decode gives %20; a second gives a space. Find which layer double-encoded it and stop encoding twice.
/ in a query value?Yes, if / is part of the value. Leave it alone when it's the path separator. encodeURIComponent and quote(..., safe='') encode it; encodeURI and default quote leave path slashes alone.
Share this article
A JWT is a signed bundle of JSON claims. Here is how to read one, why decoding is not verifying, and how to check for the classic JWT security flaws.
9 min read
Sign in to leave a comment.