URL Percent-Encoding Explained: RFC 3986, encodeURIComponent, and the %20 vs + Trap

Sep 6, 2026·
toolbox-editorial-team
· 8 min read
blog
Interactive Workbench LIVE

URL Percent-Encoding Explained: RFC 3986, encodeURIComponent, and the %20 vs + Trap

Initializing Workbench...
100% Client-Side RAM Sandbox
🔒 Private Execution: Zero server uploads.

Why URLs Need Encoding at All

A URL is not free-form text. It is a structured identifier in which certain characters carry meaning: ? starts the query, & separates parameters, # begins the fragment, / divides path segments.

The moment user data containing those characters is dropped into a URL, the structure collapses:

https://example.com/search?q=fish & chips#1
                              ^      ^     ^
                              |      |     └── parsed as a fragment
                              |      └──────── parsed as a parameter separator
                              └─────────────── raw space, invalid in a URL

The server sees the parameter q with value fish , a second empty parameter named chips, and a fragment of 1. Percent-encoding solves this by giving every problematic character an unambiguous escaped form.

https://example.com/search?q=fish%20%26%20chips%231

The RFC 3986 Character Classes

Everything follows from three sets defined in RFC 3986.

Unreserved — safe everywhere, never needs encoding

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

Section 2.3 states these are equivalent whether escaped or not. Encoding them is legal but pointless, and normalisers will decode them back.

Reserved — structurally meaningful, must be encoded inside data

GroupCharactersRole
gen-delims: / ? # [ ] @Separate the major URL components
sub-delims! $ & ' ( ) * + , ; =Delimiters inside a component

Everything else — must be encoded

Spaces, control characters, ", <, >, \, ^, `, {, |, }, and every non-ASCII character.

The Percent-Encoding Rule

character  ->  UTF-8 bytes  ->  %HH for each byte
CharacterUTF-8 BytesEncoded
space20%20
&26%26
/2F%2F
%25%25
éC3 A9%C3%A9
E2 82 AC%E2%82%AC
🚀F0 9F 9A 80%F0%9F%9A%80

RFC 3986 §2.1 says the hexadecimal digits should be uppercase; normalisers uppercase them, and %2f and %2F are equivalent on decode.

Advertisement Sponsored

The Three JavaScript Functions

FunctionLeaves UnencodedVerdict
encodeURIComponent()A-Z a-z 0-9 - _ . ! ~ * ' ( )Use this for values
encodeURI()The above plus ; / ? : @ & = + $ , #Only for a whole URL
escape()Legacy, emits non-standard %uXXXXNever use — deprecated in Annex B

The Decision Rule

Encoding a piece of data? encodeURIComponent. Encoding an already-assembled URL? encodeURI.

const query = 'fish & chips';
const redirect = 'https://example.com/next?id=7';

// Correct — each value encoded independently.
const url =
  `https://example.com/search?q=${encodeURIComponent(query)}` +
  `&next=${encodeURIComponent(redirect)}`;

// Wrong — encodeURI leaves & and = intact, so the nested URL's
// own parameters merge into the outer query string.
const broken = encodeURI(`https://example.com/search?next=${redirect}`);

That second case — a URL nested inside a query parameter — is the classic failure. encodeURI preserves ? and & because it assumes they are your delimiters, and an OAuth redirect_uri or a ?next= parameter promptly falls apart.

Where encodeURIComponent Diverges from RFC 3986

encodeURIComponent leaves !, ', (, ), and * unescaped, although RFC 3986 classifies them as sub-delims rather than unreserved. Most servers cope, but strict parsers and signature-verifying APIs — AWS Signature V4 and OAuth 1.0a among them — do not. For strict compliance:

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

%20 or +: Two Specifications, One Space

This trips up experienced engineers because both are correct — in different contexts.

ContextSpace BecomesGoverning Specification
Path segment%20RFC 3986
Fragment%20RFC 3986
encodeURIComponent() output%20ECMA-262
HTML form GET submission+application/x-www-form-urlencoded
URLSearchParams serialisation+WHATWG URL Standard
const p = new URLSearchParams();
p.set('q', 'fish & chips');
p.toString();                       // "q=fish+%26+chips"

encodeURIComponent('fish & chips'); // "fish%20%26%20chips"

Both round-trip correctly through a form-encoding parser. But a generic RFC 3986 parser reading a path decodes + as a literal plus sign. The practical consequences:

  • A + in a path segment is a plus, not a space. /files/my+file.txt refers to a file whose name contains a plus.
  • Encode a literal plus in form data as %2B. Otherwise user+tag@example.com arrives as user tag@example.com — the reason so many sites mangle plus-addressed email.
  • Pick one serialiser per codebase. Mixing URLSearchParams with hand-built strings produces query strings that decode inconsistently.

Failure Modes Worth Recognising

1. Double Encoding

Percent-encoding is not idempotent, because % is itself an encodable character.

"a b"  -> encode -> "a%20b"  -> encode again -> "a%2520b"

The server decodes once and hands your application the literal string a%20b. Symptom: values that visibly contain %20, %3D, or %26 after parsing. Cause: a value encoded at construction time and encoded again by a helper, a proxy, or a template. Encode exactly once, as late as possible.

2. Encoded Slashes Rejected by the Server

%2F inside a path segment is legal, but Apache HTTP Server defaults to AllowEncodedSlashes Off and returns 404 for such requests. Never place a value that might contain / into a path segment — put it in the query string instead.

3. URIError: URI malformed

decodeURIComponent throws on a truncated or invalid escape such as %E0%A4 or %ZZ. Any decode of untrusted input needs a guard:

function safeDecode(value) {
  try {
    return decodeURIComponent(value);
  } catch {
    return value; // Malformed input — return it untouched rather than crashing.
  }
}

4. Non-ASCII Hostnames Are Not Percent-Encoded

Percent-encoding applies to the path, query, and fragment. Internationalised domain names use Punycode (RFC 3492/5891) instead: münchen.de becomes xn--mnchen-3ya.de. Encoding a hostname with encodeURIComponent produces an unresolvable name.

5. Encoded Data in Logs Is Still Sensitive

Percent-encoding is not obfuscation. Anything placed in a query string lands in server access logs, proxy caches, Referer headers, and browser history. Tokens and secrets belong in headers or a request body — a point worth remembering when signing webhook requests.


When to Reach for Base64 Instead

Percent-encoding is designed for text that is mostly URL-safe already. Encoding binary data with it is wasteful — every byte becomes three characters.

For binary payloads inside a URL, use base64url (RFC 4648 §5), which substitutes - and _ for + and / and drops padding, producing a string made entirely of unreserved characters. This is exactly what JWT segments use. The Base64 encoding and Data URI guide covers the mechanics, and the JWT security guide shows base64url in production.


Step-by-Step: Encoding with Toolbox

  1. Open the tool: visit the Toolbox URL Percent Encoder & Decoder.
  2. Choose a tabEncode to convert raw text into percent-encoded form, Decode to reverse it.
  3. Paste the value, not the whole URL. Encode one query parameter value or one path segment at a time, then assemble the URL yourself.
  4. Read the output — the encoder applies the browser’s native encodeURIComponent, so UTF-8 multi-byte characters convert correctly.
  5. Copy the result and drop it into your request, config file, or test case.
  6. Diagnose double encoding by pasting a suspect value into the Decode tab. If one pass still leaves % sequences behind, the value was encoded twice upstream.

Encoding runs entirely on your device through the browser’s own URL functions, so tokens, search terms, and customer identifiers in the strings you are debugging are never transmitted.

FAQ

Frequently Asked Questions

What is the difference between encodeURI and encodeURIComponent?

encodeURI is for a complete URL and deliberately leaves the reserved structural characters ; / ? : @ & = + $ , # untouched so the URL keeps working. encodeURIComponent is for a single piece of data being placed inside a URL, such as one query parameter value, and escapes those reserved characters as well. Use encodeURIComponent for values and encodeURI only when handling an entire URL string.

Why does a space sometimes become %20 and sometimes a plus sign?

Percent-encoding under RFC 3986 always represents a space as %20. The plus sign comes from a different and older specification, application/x-www-form-urlencoded, which HTML forms and the URLSearchParams API use for query strings. Both forms are decoded as a space by a form-encoding parser, but a generic RFC 3986 parser treats a literal plus as a plus, which is why the same string can decode differently on two servers.

Which characters never need to be percent-encoded?

RFC 3986 section 2.3 defines the unreserved set as the ASCII letters A to Z and a to z, the digits 0 to 9, and the four symbols hyphen, period, underscore, and tilde. These characters are equivalent whether encoded or not, so encoding them adds length without changing meaning. Every other character is either reserved, meaning it has structural significance, or must be encoded.

Why did my URL break after being encoded twice?

Percent-encoding is not idempotent. The percent sign is itself a character that must be encoded as %25, so running an already-encoded string through the encoder again turns %20 into %2520. The receiving server decodes only once and reads a literal %20 in the value. Encode exactly once, at the point where a raw value is inserted into a URL, and never on a string that already contains percent-escapes.

How are non-ASCII characters such as accented letters encoded?

RFC 3986 section 2.5 specifies that a character is first converted to its UTF-8 byte sequence, then each byte is percent-encoded individually. The letter e-acute becomes the two bytes 0xC3 0xA9, which encode as %C3%A9. This is also why the legacy JavaScript escape function must never be used: it emits a non-standard %uXXXX form that is not valid percent-encoding.