URL Encoding and Decoding: A Developer Guide
Why URLs need percent-encoding, what each component requires, how plus signs behave in query strings, and a safe encode and decode workflow for APIs and forms.
By Rehan Baqir
Learn more
Percent-encoding is a one-time substitution, and it does not hide the query string from history, logs, or referrers. It replaces an unsafe or reserved character with % and two hex digits. Encode each component once, when you build the URL. Encoding the finished string, or encoding again, turns a space that was already %20 into %2520. Try a value in the URL encoder or the URL decoder. While you are debugging a payload, the JSON formatter shows control characters that survived the decode, and the regex tester is there if you are matching those sequences in a log line.
A search link that shows q=hello%2520world and returns no results has usually been encoded twice. One layer turned the space into %20. The next layer turned the percent sign into %25, leaving %2520.
Two rules cover most of the bugs: encode individual components, and encode exactly once at the boundary where you assemble the URL. Encoding the whole string after you have already joined it also encodes the ?, =, and & that the URL needs as structure.
URL anatomy: encode the right piece
https://example.com:443/path/to/page?name=John+Doe&tag=c%2Fa#section
|_____| |_________|_|____________| |______________| |__|
scheme host port path query fragment
Each component has different reserved characters:
| Component | Encode? | Notes |
|-----------|---------|-------|
| Scheme | No | https is fixed |
| Host | Rarely | Internationalized domains use punycode |
| Path | Sometimes | / is a delimiter, and it stays unencoded between path segments |
| Query values | Yes | &, =, +, and spaces need care |
| Fragment | Sometimes | The server does not receive it; client routing still uses it |
Encoding the entire URL as one string breaks those structural characters. Encode each parameter value, then assemble the URL.
What percent-encoding does
An unsafe character becomes % plus two hex digits of the UTF-8 byte:
| Character | Encoded |
|-----------|---------|
| space | %20 |
| & | %26 |
| = | %3D |
| + | %2B |
| / | %2F |
| ? | %3F |
| # | %23 |
| é | %C3%A9 (UTF-8 bytes) |
The name "percent-encoding" comes from the % prefix.
Use the URL encoder to see how a specific string changes.
encodeURI and encodeURIComponent
encodeURI is for a full URL. It leaves :/@?#[] and similar characters in place.
encodeURI("https://example.com/a b");
// https://example.com/a%20b
encodeURIComponent is for a query parameter value. It encodes / and ? along with the rest.
encodeURIComponent("a/b?c");
// a%2Fb%3Fc
Use encodeURIComponent for values. Use encodeURI only when you already have a full URL, which is uncommon if you build URLs with URLSearchParams.
The plus sign
In application/x-www-form-urlencoded (HTML forms), a space becomes +.
In URL paths, and in strict RFC 3986 encoding, a space becomes %20.
Decoders in most frameworks accept + as a space in query values. Do not rely on + in a path.
When a space works locally and fails in production, check whether one layer decodes + and another does not.
The URL decoder shows both forms side by side.
Building query strings
This breaks when query contains & or =:
const url = `/search?q=${query}`; // breaks if query contains & or =
Build the string with URLSearchParams instead:
const params = new URLSearchParams({ q: query, page: "1" });
const url = `/search?${params.toString()}`;
URLSearchParams encodes each key and each value.
If you assemble the string yourself, encode each value with encodeURIComponent and join with &:
/search?q=hello%20world&tag=node%2Fjs
Decode once, at the layer that consumes the value
Servers and frameworks decode query parameters on their own. The usual failure is a chain:
- The client encodes
- The server decodes
- The application encodes again before storage
- The display decodes again
Each layer should encode outbound data once and decode inbound data once.
Use the URL decoder to see the string after one decode pass.
Encoding twice
Original: 100% done
First encode: 100%25%20done
Second encode: 100%2525%2520done
Links that pass through several systems (email, then a CRM, then a redirector) can pick up another encoding layer at each hop. The symptom is a search term that looks right in the address bar and matches the wrong row in the database.
Decode in a loop until the string stops changing, or compare against a known double-encoded pattern such as %25 standing in for %.
Paths with special characters
A file path in a URL needs each segment encoded:
/files/2024/Q1 Report.pdf ❌ (spaces)
/files/2024/Q1%20Report.pdf ✓
A slash inside one path segment (a filename that contains /) has to be encoded:
encodeURIComponent("a/b") → a%2Fb
Leave the slashes that separate path segments alone.
Unicode and international URLs
Browsers may display https://example.com/café as Unicode. On the wire, the domain may be punycode and the path may be percent-encoded:
/caf%C3%A9
UTF-8 encode the character before you percent-encode the bytes. A Latin-1 assumption breaks emoji and CJK text.
Base64 in a URL is a different encoding
Binary stuffed into a URL sometimes uses Base64url, which swaps in - and _ for + and /. That is an alphabet change. It is separate from percent-encoding.
A JWT in a URL needs Base64url. Running encodeURIComponent over the whole token is the wrong step.
APIs and redirect URLs
An OAuth redirect_uri has to match the registered value byte for byte. An encoding difference produces redirect_uri_mismatch.
Common misses:
- A trailing slash encoded in one place and left raw in the other
httpon one side andhttpson the other- Query parameter order, which some providers compare strictly
Log the exact bytes you send, and keep the pretty-printed version for reading only.
HTML links and JavaScript URLs
<a href="/search?q=hello world"> ❌ ambiguous
<a href="/search?q=hello%20world"> ✓
A template engine may escape & in attributes on its own. Check View Source. The rendered DOM can hide what was actually written.
Server-side decoding
Most frameworks provide a decoder:
decodeURIComponent(JavaScript)urllib.parse.unquote(Python)url.QueryUnescape(Go)
Form parsers also turn + into a space in query values.
Leave the raw URL encoded if it is part of a signature, such as webhook verification or AWS SigV4. Decoding before that middleware runs changes the bytes the signature was computed over.
Security
- Open redirects: check the decoded redirect target against an allowlist
- Log injection: a decoded
%0Anewline in a parameter can break a log format - SSRF: an encoded
http://127.0.0.1can slip past a filter that only looks at the raw string. Decode, then validate - XSS in a reflected parameter: URL encoding and HTML escaping are different steps. Escape again at the output layer
Values you pull out and send to a JSON API should go through the JSON formatter while you debug, so embedded control characters are visible.
Debugging checklist
| Symptom | Check |
|---------|-------|
| + appears as a literal plus | The decoder left + as a plus in the query |
| %20 is visible in the UI | The value was encoded an extra time, or the display never decoded it |
| Slashes in the path are broken | encodeURIComponent ran on the full path, not on each segment |
| Unicode looks like mojibake | The wrong charset was used before percent-encoding |
| OAuth redirect fails | Compare the encoded redirect_uri byte for byte |
Framework differences
Axios and fetch do not share one params serializer. Axios may encode differently from URLSearchParams. When a partner integration disagrees, log config.paramsSerializer output. The library default is a guess until you see that string.
In Ruby on Rails, CGI.escape and ERB::Util.url_encode treat spaces differently (+ versus %20). Mixing the helpers in one codebase shows up as redirect bugs.
In PHP, urlencode() and rawurlencode() split the same way: space as +, or space as %20. RFC 3986 prefers rawurlencode for path segments.
In Java, URLEncoder.encode uses + for spaces, which is the application/x-www-form-urlencoded style. Use URI builder classes when the path has to follow RFC 3986.
When a stack trace shows encoding in more than one layer, sketch the encode and decode boundaries. Framework middleware often decodes before your handler runs, and the handler then encodes again before storage.
Internationalized domain names
A hostname with Unicode, such as münchen.de, uses punycode on the wire (xn--mnchen-3ya.de). Punycode is separate from path and query percent-encoding, and both show up in URLs people copy from a browser. Leave punycode hostnames to a URL parser that accepts the Unicode display form.
Limitations
Percent-encoding does not conceal sensitive data. Query parameters still appear in browser history, server access logs, and referrer headers.
URL length limits still apply. Encoding makes the string longer, and many servers and CDNs reject URLs past 2,000 to 8,000 characters. Put a large payload in a POST body.
Related articles
- Understanding Base64 encoding: Base64url and percent-encoding side by side
- Understanding regular expressions: parsing log lines that contain encoded parameters
- JSON formatting guide: API payloads that include encoded fields
Where the bugs come from
URL encoding turns a character that would break URL structure into a %XX sequence. Encode each component when you build the URL, decode once when you consume it, and know whether + means a space in that context. Most broken production links come from a second encode pass, or from encodeURI where the value needed encodeURIComponent.
Frequently Asked Questions
Common questions answered to help you get the most from this tool.