iKit
Tutorial · 10 min read ·

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 (2026)

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 %20 for any literal space.
  • The fix is usually one missing or duplicate call to encodeURIComponent on 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 curl exactly as the access log shows it. If curl also gets 400, the URL is the bug. If curl succeeds, the client is sending something else — likely a header.
  • Check the Content-Type header. A JSON body with Content-Type: text/plain is a frequent 400 cause for strict APIs that refuse to guess.
  • Check the body length against the Content-Length header. 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

Related on iKit

Related posts