withastro/astro · error · Error
Invalid URL encoding
Error message
Invalid URL encoding
What it means
Astro fully decodes every request pathname so that middleware and routing always operate on the same real path. validateAndDecodePathname() throws this plain Error when the very first decodeURI() fails - the path contains percent-encoding that cannot be decoded at all, such as a lone `%` not followed by two hex digits. The request cannot be mapped to a real path safely, so it is rejected.
Solutions
- Find the offending URL in the request/error logs and fix the producer: encode a literal `%` as `%25` (encodeURIComponent('50%off') yields '50%25off').
- If the path originates from user input or an external system, validate or reject paths with broken escapes before they reach routing (edge rule or middleware that returns 400).
- Audit any proxy/CDN rewrite rules in front of Astro so already-encoded paths pass through unchanged.
Example fix
// before - raw value with a literal % ends up in the URL
<a href={`/shop/${coupon}`}> // coupon = '50%off' -> /shop/50%off
// after - encode dynamic path segments exactly once
<a href={`/shop/${encodeURIComponent(coupon)}`}> // /shop/50%25off Defensive patterns
Strategy: validation
Validate before calling
function isDecodablePathname(pathname: string): boolean {
try {
decodeURI(pathname);
return true;
} catch {
return false;
}
}
// in middleware or an edge handler:
// if (!isDecodablePathname(url.pathname)) return new Response('Bad Request', { status: 400 }); Prevention
- Always pass dynamic path segments through encodeURIComponent(), exactly once.
- Never build URLs by concatenating raw user input.
- Treat undecodable paths as 400 Bad Request at your edge (CDN rule or middleware) rather than letting them surface as server errors.
When it happens
Trigger: A request whose path contains a stray or truncated percent sign: /shop/50%off, /foo%.pdf, or /a%2 (incomplete escape); links built by concatenating raw user input into URLs without encodeURIComponent; crawlers and security scanners probing with malformed escapes.
Common situations: Marketing or affiliate links containing a literal percent (like '50% off') that were never encoded; a proxy or rewrite layer mangling paths and leaving a dangling `%`; fuzzing tools sending garbage percent sequences that surface as request failures in logs.
Related errors
- URL encoding depth exceeded the maximum number of decode…
- IncorrectStrategyForI18n
- InvalidI18nMiddlewareConfiguration
- MissingMiddlewareForInternationalization
- A collision will result in a hard error in following…
AI-assisted analysis of withastro/astro@52e6c34790 (2026-08-18).
Data as JSON: /api/errors/416fae3071779d8a.
Report an issue: GitHub.
Appendix: source
Thrown at packages/astro/src/core/util/pathname.ts:44
* encoded several times ends up as a single, final path. This stops someone
* from sneaking a path like `/admin` past middleware by encoding it multiple
* times — middleware always sees the real, decoded path.
*
* @param pathname - The path to decode
* @returns The final, fully decoded path
* @throws Error if the path has broken encoding that can't be decoded at all
* (for example a lone `%` that isn't followed by two hex digits)
* @throws MultiLevelEncodingError if the path is still changing after
* {@link MAX_DECODE_ITERATIONS} tries (it was encoded too many times).
* Handing back a half-decoded path here would bring back the security hole
* this function exists to close.
*/
export function validateAndDecodePathname(pathname: string): string {
let decoded: string;
try {
decoded = decodeURI(pathname);
} catch (_e) {
throw new Error('Invalid URL encoding');
}
// Keep decoding until the path stops changing. A path can be encoded more
// than once (for example %2561 → %61 → a), and we want the final decoded
// path so the rest of Astro — especially middleware security checks —
// always sees the same real path, no matter how many times it was encoded.
let iterations = 0;
while (decoded !== pathname) {
// The path is still changing after the maximum number of tries, so it
// was encoded too many times for us to fully decode. Stop and reject
// it: handing back a half-decoded path could let middleware check one
// path while a later decode (during rewrite routing) turns it into a
// different, possibly protected, path.
if (iterations >= MAX_DECODE_ITERATIONS) {
throw new MultiLevelEncodingError();
}
pathname = decoded;
try {
decoded = decodeURI(pathname);View on GitHub (pinned to 52e6c34790)