URL Encoding in 2026: The Traps of Percent-Encoding, %20, + and Double Encoding

URL encoding looks harmless: a space becomes %20, an umlaut becomes %C3%BC, done. In practice it is one of the most common silent sources of bugs in web applications. Sometimes a space shows up as %20, sometimes as +. Non-ASCII characters split into two percent pairs. And encode once too often and you get %2520 instead of %20 – a bug that reaches all the way to a security hole. This article explains reserved and unreserved characters per RFC 3986 and shows where the traps hide.

Why URL encoding exists at all

A URL is at its core a string built from a very small, clearly defined alphabet. The standard for it is RFC 3986 from 2005, which remains the authoritative basis to this day (as of July 2026). It defines which characters may appear directly in a URL and which must be rewritten first. Anything that does not fit the allowed alphabet is turned into the form %HH via percent-encoding (also called URL encoding) – a percent sign followed by two hexadecimal digits.

The reason is practical: a URL travels through many systems – browsers, proxies, web servers, log files, email clients. For it to be interpreted the same everywhere, it may only consist of a robust ASCII core. Characters like the space, the question mark, or an ampersand carry structural meaning in a URL – they separate parts. When such a character appears inside a value, it has to be encoded, otherwise the structure breaks.

So percent-encoding is not an encryption or security mechanism, but pure transport encoding. Encoded data is trivially reversible and offers zero protection; its only purpose is to move arbitrary bytes safely through the tight grammar of a URL.

Reserved versus unreserved: the character classes of RFC 3986

RFC 3986 divides characters into groups, and that distinction is the key to the whole topic. The unreserved characters may always appear unencoded and must never be encoded unnecessarily. These are exactly: the letters A–Z and a–z, the digits 0–9, plus the four special characters -, ., _ and ~. These 66 characters are the safe core of every URL.

The reserved characters in turn have a structural role. They split into the gen-delims : / ? # [ ] @ and the sub-delims ! $ & ' ( ) * + , ; =. A / separates path segments, a ? starts the query, a # the fragment, an & separates query parameters. As separators they stay unencoded; as part of a value they must be percent-encoded – / becomes %2F, ? becomes %3F, & becomes %26.

This is where the first big trap appears: whether a reserved character has to be encoded depends on context. An & in the surname "Meyer & Son" must become %26 inside a query value, otherwise the server treats it as a parameter separator; between two parameters it stays as is. Same character, two meanings.

The space problem: %20 versus +

Almost nothing confuses as reliably as the question of whether a space becomes %20 or +. Both are correct – but in different contexts. In a real URL per RFC 3986 a space becomes %20. That holds in the path, in the fragment, and generally everywhere the URL grammar applies.

The + as a space comes from a different, older standard: the media type application/x-www-form-urlencoded that HTML forms use when submitting. There – and only there – a + stands for a space, and a real plus sign must therefore be encoded as %2B. This rule is part of the HTML specification and lives on to this day in the query string of many applications.

The chaos arises because both conventions meet in the same query string. A server that parses the query part as x-www-form-urlencoded turns a+b into a b; another that follows RFC 3986 strictly returns a+b literally. Build a search with a real plus sign – say "C++" – and fail to encode it as %2B, and you may get "C " with two spaces at the other end. Rule of thumb: encoding a space as %20 is always safe, a plus sign in a value always as %2B.

Non-ASCII and UTF-8: why ü becomes %C3%BC

Percent-encoding knows only bytes, not letters. A ü is not a single byte but a Unicode character that first has to be translated into bytes. The standard for that is UTF-8, which RFC 3986 explicitly recommends for new URI schemes. In UTF-8 ü takes two bytes, 0xC3 and 0xBC; percent-encoded that gives %C3%BC – which is why accented characters always produce two percent pairs.

The pattern runs through all German special characters: ä becomes %C3%A4, ö becomes %C3%B6, ß becomes %C3%9F, capital Ü becomes %C3%9C. Characters outside the Latin range need more bytes: the euro sign becomes three bytes %E2%82%AC, an emoji even four. Depending on the character, the percent sequences grow.

The trap is called charset mismatch. If one side encodes in UTF-8 and the other decodes in Latin-1 (ISO-8859-1), %C3%BC turns into the infamous mojibake ü. Old Latin-1 systems produce a single %FC for ü that a UTF-8 parser no longer understands. To transport non-ASCII characters reliably, both sides must use the same encoding – when in doubt, UTF-8 everywhere.

encodeURIComponent versus encodeURI in JavaScript

JavaScript has two built-in functions for encoding, and mixing them up is one of the most common mistakes there is. encodeURI() is meant to encode a complete URL. It therefore leaves untouched every character a URL needs structurally: : / ? # [ ] @ ! $ & ' ( ) * + , ; = stay in place. That is exactly right when you have a whole address in which those characters are meant to act as separators.

encodeURIComponent() in contrast is meant for a single component – the value of a query parameter, say. It encodes considerably more, including /, ?, &, = and #, and leaves only the unreserved characters plus a few legacy characters (! ~ * ' ( )) in place. That is exactly what you need when a value itself might contain separators.

The classic bug: you build "...?q=" + encodeURI(term) and wonder why an & or = in the search term tears the query apart – encodeURI deliberately leaves those characters standing. The right call is encodeURIComponent(term), because the term is a value. Conversely, do not turn encodeURIComponent loose on a whole URL, or https:// becomes a useless https%3A%2F%2F. Rule: encodeURI for whole URLs, encodeURIComponent for single values.

Where in the URL you encode: path, query and fragment

A URL consists of several zones, each with its own rules. In the path (after the host up to the ?) the / separates segments. A / that belongs to a value and is not meant as a separator must become %2F. Some web servers even reject encoded slashes in the path for security reasons – a stumbling block in API design.

The query (after the ?) consists of key=value pairs separated by &. Encoding matters most here, because values can contain practically any text. Every =, every & and every # inside a value must be encoded, otherwise the parameter boundary shifts. The query is also where the +-equals-space convention lives, which does not touch the path.

The fragment (after the #) never reaches the server but stays in the browser. The same character rules apply nonetheless, and whoever stores, say, a JSON state in the fragment must encode its special characters. Treating it as "irrelevant" because it is not transmitted is a mistake – client-side code very much reads it.

Double encoding: the bug that becomes a security hole

The most insidious mistake is double encoding. It happens when an already encoded value runs through the encoder a second time. On the first pass the space becomes %20. The percent sign in it is itself a special character and becomes %25 on the second pass – so %20 turns into %2520. If the receiver decodes only once, it gets a literal %20 instead of a space.

In practice this happens constantly at interfaces: a frontend encodes a value, a framework encodes the URL again, a proxy decodes once, the server a second time. Any layer that encodes or decodes unasked can tip the balance. The result is broken links, filenames with a visible %20 in the text, or search hits that find nothing because the search term reads %2520.

It becomes security-critical when a filter expects only one decode round. An attacker writes %252e%252e%252f instead of ../. The first decode step turns it into %2e%2e%2f, which slips past the path-traversal filter; a second step then restores ../. Such double-encoding bypasses have been in the OWASP testing guide for years and are the reason for consistent, single encoding at well-defined boundaries.

Common mistakes in practice

The first classic is building query strings by string concatenation without encoding the values. As soon as a value contains an &, = or space, the structure breaks. The clean fix is to handle each value separately with encodeURIComponent (or its server-side equivalent) – or to use a URL builder library outright.

The second is encoding at the wrong time: values are stored already encoded in the database and encoded a second time when served. Encoding belongs at the boundary to the URL, not in persistence. The database holds the raw value; encoding happens only when it moves into a concrete URL.

The third concerns slugs. Put a title with non-ASCII characters straight into a URL and you get %C3%BC chains nobody wants to read or share. Better is a clean slug that resolves accented characters to ue, ae, oe and leaves only unreserved characters. Then the URL needs no encoding at all.

How tools on CalcSI help

When you have a suspicious or broken URL in front of you, the fastest way to take it apart is the URL Encoder/Decoder: it shows you what hides behind %C3%BC or %2520, and it exposes double encoding – decode twice and see whether anything still changes. For readable, encoding-free addresses, generate clean URL slugs from titles with the Slug Generator, which drop the percent sequences entirely. When you need to pack binary data robustly into a URL, Base64 is often the better transport encoding – watch for the URL-safe variant that avoids + and /. And when you want to hunt for percent patterns like %25[0-9A-Fa-f]{2} in logs or input, build and test the expression in the Regex Tester. All four run entirely in the browser, so your URLs never leave your machine.

Comments