
Two bytes. That is all CRLF injection needs: a carriage return and a line feed, %0d%0a, the exact pair HTTP uses to end one header and start the next. Slip them into a value the server writes into a response header and you stop being a user supplying data; you start being the one deciding where headers end. The surprising part is why this still appears in 2026: header values are usually assembled before any output-encoding layer runs, so a string that is perfectly HTML-encoded for the body is wide open in a header.
This post explains the mechanics, walks a confirmed response-splitting exploit byte for byte, shows the cookie-injection and cache-poisoning escalations, and ends on the one-line fix. CRLF injection is tested under WSTG-INPV-15 and maps to A03:2021 Injection.
CRLF injection inserts carriage-return (CR, %0d, \r) and line-feed (LF, %0a, \n) characters into application output that becomes part of an HTTP response, breaking the intended structure of the message. HTTP uses \r\n to end each header line and a doubled \r\n\r\n to mark the end of headers and the start of the body, so injecting those bytes lets you forge structure the server never intended.
The vulnerable pattern is any place user input lands in a response header: a redirect reflecting a url parameter into Location, a language preference written into Set-Cookie, or a custom header echoing a request value. Reverse proxies and CDNs that copy request headers into responses are a frequently overlooked sink. CRLF injection is the gateway to header injection and response splitting, both tested in the input-validation portion of a web application pentesting checklist.
It works by smuggling encoded newlines into a value the server writes into a header, so your text after the newline becomes a new header line. Take an endpoint that redirects using a parameter it reflects into Location. Supplying an encoded CRLF appends a header of your choosing, and the raw response confirms it:
GET /redirect?url=/home%0d%0aSet-Cookie:%20sessionid=attacker HTTP/1.1
Host: app.target.tld
HTTP/1.1 302 Found
Location: /home
Set-Cookie: sessionid=attacker <-- forged header, injected by you
Content-Length: 0That injected Set-Cookie appearing as its own line in the raw response is the confirmation. The reason output encoding does not save you here is that the value was newline-stripped for the body, not for the header, and those are different channels. Pinning a known session ID into the victim's browser this way is session fixation; the same primitive scaled up becomes response splitting.
Doubling the CRLF closes the header block entirely and lets you write your own body, which is full HTTP response splitting, and the injected body is where XSS comes back from the dead. Here is the request and the raw response, with the injected second response and script body called out:
GET /redirect?url=%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a<script>alert(document.domain)</script> HTTP/1.1
Host: app.target.tld
HTTP/1.1 302 Found
Location:
Content-Length: 0
HTTP/1.1 200 OK <-- a second, fully attacker-controlled response
Content-Type: text/html
<script>alert(document.domain)</script> <-- script body the encoder never touchedThe script tag landing in a fresh body is the confirmation. On a recent assessment of a retail site, I chained this with the CDN sitting in front: the response on that path was cacheable, so the poisoned variant got served to every subsequent visitor of the URL, turning a self-only reflection into a stored, cross-user XSS and a far higher CVSS score. Always view the unrendered response in Burp Suite, never the browser, because the browser hides exactly the byte-level structure you are confirming.
Find every parameter that influences a response header, inject encoded CRLF sequences, and read the raw response for a line you control. Watch redirect (Location), cookie, and any reflected custom headers, and test the double-encoded variant for filters that decode once.
# Core payloads (try each in every header-reflecting param)
%0d%0aSet-Cookie:%20test=crlf
%0d%0aX-Injected:%20yes
%0d%0a%0d%0a<script>alert(1)</script> # splitting + body
# Double-encoded, for a filter that URL-decodes once
%250d%250aX-Injected:%20yes
# Fuzz header-reflecting params at scale
ffuf -w params.txt -u "https://target/redirect?FUZZ=%0d%0aX-Injected:yes"
# match on responses where 'X-Injected: yes' appears as a header lineIf your injected header or body shows up in the raw response, it is confirmed. This is WSTG-INPV-15 and belongs in your penetration testing test cases as an explicit line item.
CRLF injection is rarely the end goal; it is a primitive that enables several higher-impact attacks depending on what you can inject. The severity ranges from log noise to full cross-user response control.
Set-Cookie to pin a victim's session ID to a value you know.Scanners detect the simple reflected-header case but miss the variants that matter: filters defeated by double encoding, input that reaches a header only after a second backend hop, and the escalation from header injection to cache poisoning. A tool sends %0d%0aX-Injected:yes, sees its own header echoed, and stops, even when the same sink lets you write a full body for XSS. The false positive to watch is a reflected header the framework later strips or the browser normalizes away; confirm by reading the raw bytes.
The false negatives are worse on modern stacks precisely because most HTTP libraries silently strip \r\n, so a naive payload returns clean and the scanner clears the endpoint, but a Unicode or container-decoded variant still slips through on a proxy or legacy gateway in the chain. Header-reflecting values buried in JSON or in X-Forwarded-* echoes sit outside a scanner's parameter model entirely. Fuzz every header-influencing parameter with both %0d%0a and %250d%250a, then check cacheability of any reflected response. Continuous coverage of injection classes like this is exactly what agentic pentesting is built to automate.
Prevent CRLF injection by removing or rejecting CR and LF from any value before it is written into an HTTP header, and by relying on framework APIs that do this for you. Most modern HTTP libraries already refuse header values with raw newlines: Java's HttpServletResponse.setHeader throws on CR/LF, Node's http module rejects them with ERR_INVALID_CHAR, and ASP.NET strips them by default. The bug therefore surfaces where developers build raw header strings by hand, run an older platform, or set headers at a reverse proxy or CDN edge that does its own parsing.
Concretely: strip or URL-encode \r and \n in redirect targets, cookie values, and any reflected header; validate redirect destinations against an allowlist of paths rather than reflecting raw input; mark cache keys so a poisoned variant cannot be served to others; and never log unsanitized request data on the same line as structured fields. As with every injection class, the root fix is the same principle from the OWASP Top 10: keep data and protocol structure strictly separate, and never let a client-supplied byte decide where one header ends and the next begins.