How to Debug a 400 Bad Request With URL Decoding (2026)
A 400 Bad Request usually traces to a URL decoding bug. Here's how to decode the query string, spot the broken character, and ship a fix in five minutes.
How to Debug a 400 Bad Request With URL Decoding
Your endpoint returns 400 Bad Request and the body looks fine. Nine times out of ten the culprit is in the URL — a stray %, a + that wasn't a space, a character the spec called reserved. This guide walks through URL decoding as a debugging tool: take a broken request, pull it apart, and find the byte that's tripping the parser.
TL;DR
- A 400 with no error body almost always means a malformed URL the parser rejected before your handler ran.
- Decode the URL once — never twice. Double-decoding masks the original bug.
+is a space only in query strings, not in paths. Use%20for any literal space.- The fix is usually one missing or duplicate call to
encodeURIComponenton the client. - Decode any URL locally with the iKit URL Encoder/Decoder — no upload, no log.
What a 400 Bad Request Actually Means
A 400 is not "your data was wrong." It is "the request itself was malformed." That distinction matters because it tells you where to look for the bug. RFC 9110 §15.5.1 defines 400 as a request the server cannot or will not process due to something perceived as a client error — malformed syntax, invalid framing, or deceptive routing. The phrasing is deliberately vague because the failure could happen at any layer between the wire and your route handler.
The HTTP spec answer
400 is the catch-all for client-side malformation. It is explicitly not a validation error (that would be 422), not a wrong method (405), not an unsupported media type (415). When a server returns 400 with no detail, the most common reason is that the failure happened in the URL-parsing stage — the parser rejected the URL itself before any code that could produce a useful error message got to run.
Where URL encoding fits in
HTTP routes, query strings, and form bodies all rely on percent-encoding to carry characters that have meaning to the URI parser — spaces, slashes, ampersands, equals signs. The rules for what needs encoding live in RFC 3986 §2. When a client encodes wrongly, the server's URI parser fails first, before any business logic runs. That's why the response has no JSON body and the access log entry ends right after the URL.
Why a 400 from a URL looks like an application bug
The clue is the lack of context. A real application error gives you a code, a message, sometimes a stack trace. A URL-parsing 400 gives you a bare status line, maybe a generic "Bad Request" body, and an access-log entry with no application trace ID. If your handler logs are silent but the load balancer recorded a 400, the URL is the suspect.
URL Decoding and the 400 Bad Request: Decode the Query String in 30 Seconds
The fastest debugging loop is three steps. None of them require leaving the browser.
Step 1 — Copy the raw URL from the log
Don't copy from the browser address bar. Browsers decode URLs for display, which hides exactly the bug you're hunting. Grab the URL from the access log, the proxy log, or the network panel's "Copy as cURL" option. You want the exact bytes the server saw.
Step 2 — Decode it once
Paste it into a decoder and click Decode. The output is what the server thought you were asking for. Any mismatch between this and your intended request is the bug.
// In the DevTools console — never touches a server
decodeURIComponent('order%2520123')
// → 'order%20123' ← still encoded! Double-encoded bug.
The browser's built-in decodeURIComponent does the same thing as any online tool. If you'd rather use a UI, the iKit URL Encoder/Decoder runs in-tab via that exact browser primitive — no network round-trip and no log entry on a third-party server.
Step 3 — Compare against what the endpoint expected
Open the API spec or the handler source side-by-side. If the handler expects ?ids=1,2,3 and the decoded URL shows ?ids=1%2C2%2C3, you've found it — the comma was encoded somewhere upstream that should have left it alone. If the handler expects a JSON-stringified body and the URL ends mid-string, you're looking at a request that lost bytes in transit.
The Five URL-Decoding Bugs Behind Most 400s
The same handful of patterns accounts for almost every URL-related 400 I've debugged. Recognise them on sight and you'll close most tickets in minutes.
1. Double-encoded percent signs (%2520 instead of %20)
A space gets encoded to %20. If your code calls encodeURIComponent twice, the % itself gets encoded into %25, turning %20 into %2520. The decoder then produces %20 literally — the server sees a percent sign followed by digits, decides it's not a valid escape, and rejects. The fix is to find the duplicate encodeURIComponent in your client. Often one happens inside your HTTP library and you added another manually "just to be safe."
2. + versus %20 in the wrong segment
In application/x-www-form-urlencoded query strings, + decodes to space — a leftover from HTML form encoding, formalised in the WHATWG URL Standard §application/x-www-form-urlencoded. In path segments, + is just a literal +. A URL like /users/john+doe does not mean /users/john doe. Encode spaces in paths as %20, always.
3. Reserved characters that needed encoding
RFC 3986 §2.2 lists the reserved set: :/?#[]@!$&'()*+,;=. If any of these end up in a query value unencoded, the parser splits at the wrong place. Classic case: an email address with + aliasing in a query param, encoded with encodeURI instead of encodeURIComponent. The + survives, gets decoded as space at the server, and the address fails validation. If you're unsure when each JavaScript function applies, our encodeURIComponent vs encodeURI walkthrough lays out the boundary rules.
4. UTF-8 byte sequences encoded as Latin-1
Modern URL encoding is UTF-8: a Japanese character takes three bytes, each encoded as a %XX triplet. If the client encodes in Latin-1 or Windows-1252, the server gets one byte where it expected three. The decode succeeds (still valid percent-encoding), but the resulting string isn't valid UTF-8 — many strict parsers reject it at that stage with a 400.
5. Trailing whitespace, BOM, or control characters
A URL copied from a chat client sometimes carries a trailing \n, a %0A, or a zero-width Byte Order Mark %EF%BB%BF at the start. The server's URL parser is strict and refuses the request. Trim before sending. Hex-dump the URL bytes if you suspect this — it's the bug that's invisible to the eye but obvious to xxd.
A Worked Example: Search Endpoint Returning 400
Here is an anonymised case from a ticket I closed last month. Total debug time was four minutes once the URL was in front of me.
What the request looked like
The client SDK was sending:
GET /search?q=foo%20%26%20bar HTTP/1.1
Host: api.example.com
Browser said 400. Server said 400. Application logs were empty.
What the decoded URL showed
Pasting foo%20%26%20bar into a decoder produced foo & bar — exactly what the user typed in the search box. So the URL was, in isolation, well-formed. The bug had to be on the server's expectations.
The server's query parser was written against application/x-www-form-urlencoded rules, where + is the space character. The client SDK, doing what every modern URL encoder does, used %20 for spaces. The server's parser saw %20, applied form-encoding rules strictly, and rejected the request as malformed.
The one-line fix
Two-line change in the server's middleware to accept both space encodings:
# Before — strict form parsing, rejects %20
qs = parse_qsl(request.query_string,
strict_parsing=True)
# After — normalise %20 to + before parsing
normalised = request.query_string.replace(
b'%20', b'+')
qs = parse_qsl(normalised,
strict_parsing=True)
The decode was the entire investigation. Without it we'd have spent the afternoon reading SDK source.
Quick Reference: Where Each Character Goes Wrong
| Character | OK in path? | OK in query? | If wrong: |
|---|---|---|---|
space |
encode as %20 |
%20 or + |
400 from strict parsers |
+ |
literal + |
decodes to space | wrong value, sometimes 400 |
& |
literal & |
splits the query | wrong value, never 400 |
% alone |
invalid | invalid | 400 from URI parser |
If a value contains any character from the table above, run it through encodeURIComponent before splicing it into the URL. Always encode the values, never the whole URL — encoding the structural slashes and ampersands by accident is the source of bug #3 above.
Tools That Help (Without Leaking Your URLs)
You don't need much tooling for this, but there are two pitfalls worth flagging.
Browser DevTools
encodeURIComponent and decodeURIComponent are built into every JavaScript runtime and never make a network call. For one-off decodes, paste into the DevTools console and you're done. The Network panel's "Copy as cURL" gives you the exact bytes that left the browser — the most reliable source for "what did the server actually see."
Server-side log inspection
Access logs (nginx, Apache, ALB, Caddy) record the raw URL. Filter for status=400, then look at the URL column. If the same URL pattern accounts for most of the 400s, you've found the broken caller. Most CDN dashboards (CloudFront, Fastly, Cloudflare) have a similar 400-by-URL pivot view that surfaces the worst offenders without grepping logs by hand.
Online decoders — pick a client-side one
Many "online URL decoder" tools POST your input to a server and decode it there. For routine URLs that is harmless. For URLs containing auth tokens, signed S3 links, or password-reset codes, it is a real leak — see our URL Encode Online: Stop Pasting Sensitive URLs post for the full breakdown of who reads pasted URLs and how long they're kept. The iKit URL Encoder/Decoder runs the decode in your browser tab via the JavaScript API; nothing crosses the network.
When 400 Isn't Actually URL Encoding
If the URL decodes cleanly and matches the spec, the 400 lives somewhere else. Three quick smoke tests rule URL out as the suspect.
- Reissue the request with
curlexactly as the access log shows it. Ifcurlalso gets 400, the URL is the bug. Ifcurlsucceeds, the client is sending something else — likely a header. - Check the
Content-Typeheader. A JSON body withContent-Type: text/plainis a frequent 400 cause for strict APIs that refuse to guess. - Check the body length against the
Content-Lengthheader. Off-by-one bugs in client code that hand-rolls the body produce 400s that look like URL bugs but aren't.
If the URL passes all three, move on to body / header validation. When you're inspecting JSON payloads in the response, the iKit JSON Decoder makes the structure browsable in one paste. For randomly-generated request IDs or test tokens you need on the fly, the iKit Password Generator produces a fresh value without leaving the tab.
References
- RFC 9110 §15.5.1: 400 Bad Request — Defines 400 as a client error for malformed syntax, invalid framing, or deceptive routing.
- RFC 3986 §2.2: Reserved Characters — Lists
:/?#[]@!$&'()*+,;=— the characters that must be encoded inside URL components. - WHATWG URL Standard — application/x-www-form-urlencoded — Formalizes the
+-decodes-to-space rule that strict parsers apply. - MDN: decodeURIComponent() — Throws URIError on stray
%or non-UTF-8 sequences — the failure mode behind many 400s. - MDN: encodeURIComponent() — The single-pass encoder recommended for client-side fixes in this debugging guide.
Related on iKit
- URL Encoding: Component vs URI vs Form — the foundation primer that explains the three encoding modes behind every bug in this post.
- URL Plus Sign vs %20: When + Means Space — the deep dive on
+vs%20, including history and modern guidance. - encodeURIComponent vs encodeURI Explained — clears up which JavaScript function belongs in which spot, with examples.
- Double-Encoded URLs: Spot and Fix — the companion guide for when
%2520is the byte tripping your parser. - URL-Encode Sensitive Data Safely — the privacy angle on decoding URLs with cloud-hosted tools.
Related posts
How to Debug a 401 Unauthorized by Decoding the JWT (2026)
When an API returns 401 Unauthorized in 2026, the JWT is usually the smoking gun. Decode the token, read exp, aud, iss — and fix the bug in 90 seconds.
Convert Unix Timestamp to Date Without Timezone Bugs (2026)
Converting a Unix timestamp to a date is one division operation, but the timezone you format it in is the whole bug. Here's how to get it right.
How to Decode a JWT in 2026 — Auth0 & Firebase Examples
Decode a JWT in 2026 with real Auth0 and Firebase tokens — header, payload, signature explained, common debugging traps, and why pasting tokens online is risky.