There’s a fun caveat with what the HTTP specification says about headers versus the way they are implemented.

The story starts with RFC 9110, which addresses HTTP fields in Section 5:

HTTP uses “fields” to provide data in the form of extensible name/value pairs with a registered key namespace…

A field name labels the corresponding field value as having the semantics defined by that name. For example, the Date header field is defined in Section 6.6.1 as containing the origination timestamp for the message in which it appears.

field-name     = token

Field names are case-insensitive and ought to be registered within the “Hypertext Transfer Protocol (HTTP) Field Name Registry” …

HTTP field names are, thus, case-insensitive.

Headers, defined in Section 6.3, are one such example of a field:

The “header section” of a message consists of a sequence of header field lines. Each header field might modify or extend message semantics, describe the sender, define the content, or provide additional context.

So, a web client may send headers like Foo: Bar, foo: Bar, FoO: Bar, and so on, and the server must accept them regardless of their case.

Framework specifications, however, don’t always honor this rule. Sometimes, they offer a method, but they don’t apply it by default. Other times, they leave it entirely up to the developer to decide how to implement it.

Of course, this also leaves some wiggle room for developers not to implement it at all.

This isn’t frustrating in itself, but many web clients will canonicalize the headers before sending them out. And that isn’t in itself frustrating, but many security tools, like Burp Suite and Postman, will also canonicalize headers before sending them. This can introduce some pain-points during a web app or API assessment.

Suppose you have an API endpoint defined like this (in pseudocode):

@endpoint(method="GET", path="/userinfo")
def handler(request) is:
    if request.header["authToken"], then
        return 200, userInfo()
    else
        return 403, error()

In this example, the client must send a request whose authorization header is case-sensitive. This is a common implementation mistake with frameworks like Python, Node, and so forth.

The following request will successfully get the user’s info:

GET /v1/userinfo HTTP/2
Host: api.server.tld
authToken: my.foo.jwt
...

HTTP/2 200 OK
...

Unfortunately, because many web clients will canonicalize the header key, they will instead send this, which will fail:

GET /v1/userinfo HTTP/2
Host: api.server.tld
Authtoken: my.foo.jwt
...

HTTP/2 403 Forbidden

A simple fix here is to recommend the use of an appropriate header, such as the Authorization header, which exists to help with this exact case. However, the root cause is that the application has no consistent way to handle the different header cases, which it is required to do per the HTTP specification. The developer is left to normalize these values in order for the application to remain in compliance.

In Python, you can trivially normalize headers from a dict:

normalize = lambda s: s.lower()

headers = lambda d: dict((normalize(lower), v) for k,v in d.items())

event["headers"] = headers(event["headers"])

validate_jwt(event["headers"]["authorizationtoken"])
...

Here, the application can use only the lowercase representation of the headers. This provides a consistent means to get their values. The developer can take it a step further by refactoring normalize to convert the key strings into their canonical MIME forms: for example, transforming x-api-key to X-Api-Key, and designing the application to use this form instead.

There may be lots of corner cases involving custom headers where this problem rears its head. It’s important to be aware of the behavior, why it’s problematic, and what to do about it. It’s especially annoying when you encounter these issues in web clients and web testing tools or frameworks.

Nuclei is a great tool and lets you set custom, case-sensitive headers for all but one case: secrets from authenticated scans that are used as headers. Consider the following authenticated scan configuration:

$ cat config.yaml

env-vars: true 
templates:
  - "/templates/test.yaml"
exclude-tags:
  - noscan
secret-file:
  - "/templates/secret.yaml"
disable-update-check: true 
json: true 
target:
  - "https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7"
store-responses: true 
prefetch-secrets: true

$ cat secret.yaml

id: auth-tokens 
info:
  name: Get and set an authorization token 
  author: me
  severity: info 
  tags: noscan 
dynamic:
  - template: /templates/auth.yaml
    input: "https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7"
    variables:
      - key: x
        value: y
    domains:
      - "webhook.site"
    type: header
    headers:
      - key: bAr
        value: ""

$ cat auth.yaml

id: auth 
info:
  name: Actually get the auth token from the API 
  author: me
  severity: info 
  tags: noscan 
requests:
  - method: POST
    path:
      - "/check"
    headers:
      Content-Type: application/json
      Accept: application/json 
    body: |
      {"hello": "world"}
    matchers-condition: and 
    matchers:
      - type: dsl 
        dsl:
          - "status_code == 200"
    extractors:
      - type: json 
        part: body 
        name: barAuthToken
        json: [".bar"]$ 

The config template specifies which secrets file(s) should be used. The secrets file will run its own template, auth.yaml, which performs the request and pops the authorization tokens from the response. So long as this succeeds, the test.yaml file from the config will run with that header.

$ podman run --rm \
    -v $(pwd):/templates \
    --env-file ".env" \
    docker.io/projectdiscovery/nuclei:v3.4.4 \
    -config /templates/config.yaml \
    -vv -debug

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.4.4

                projectdiscovery.io

...

POST /4ad356ac-e855-4032-8210-903aee8a58f7/check HTTP/1.1
Host: webhook.site
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.3
Connection: close
Content-Length: 20
Accept: application/json
Accept-Language: en
Content-Type: application/json
Accept-Encoding: gzip

{"hello": "world"}
[DBG] [auth] Dumped HTTP response https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/check

HTTP/1.1 200 OK
Connection: close
Transfer-Encoding: chunked
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length,Content-Range
Cache-Control: no-cache, private
Content-Type: application/json
Date: Sat, 13 Sep 2025 16:42:02 GMT
Server: nginx
X-Request-Id: 8f6011f0-bb9a-4d12-ba3e-85eb40d7958f
X-Token-Id: 4ad356ac-e855-4032-8210-903aee8a58f7

{"bar": "SomeBarValue"}
[auth:dsl-1] [http] [info] https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/check ["SomeBarValue"]

This simulates how an API will provide an authorization token, and how Nucleus can use that response to authorize successive requests in the runner. The indication of success is the last line in this output.

Unfortunately, the authenticated scan method will convert only the secret headers to their canonical forms. Notice that the header bAr from the secrets file is automatically converted:

[test] Test the case sensitivity of the header keys (@me) [low]
[INF] [test] Dumped HTTP request for https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/test

POST /4ad356ac-e855-4032-8210-903aee8a58f7/test HTTP/1.1
Host: webhook.site
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36
Connection: close
Content-Length: 22
Accept: application/json
Accept-Language: en
Bar: SomeBarValue
Content-Type: application/json
Accept-Encoding: gzip

This is problematic for testing a non-compliant API server.

At the time of writing, this corner case accepts headers like such:

// Apply applies the headers auth strategy to the request
func (s *HeadersAuthStrategy) Apply(req *http.Request) {
    for _, header := range s.Data.Headers {
        req.Header.Set(header.Key, header.Value)
    }
}

// ApplyOnRR applies the headers auth strategy to the retryable request
func (s *HeadersAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
    for _, header := range s.Data.Headers {
        req.Header.Set(header.Key, header.Value)
    }
}

The Header.Set method actually resolves deep into the net/http package, which explicitly sets a canonical key for each header:

// Set sets the header entries associated with key to the
// single element value. It replaces any existing values
// associated with key. The key is case insensitive; it is
// canonicalized by [textproto.CanonicalMIMEHeaderKey].
// To use non-canonical keys, assign to the map directly.
func (h Header) Set(key, value string) {
    textproto.MIMEHeader(h).Set(key, value)
}

The textproto package handles the setting of these canonical values:

// Set sets the header entries associated with key to
// the single element value. It replaces any existing
// values associated with key.
func (h MIMEHeader) Set(key, value string) {
    h[CanonicalMIMEHeaderKey(key)] = []string{value}
}
...

// CanonicalMIMEHeaderKey returns the canonical format of the
// MIME header key s. The canonicalization converts the first
// letter and any letter following a hyphen to upper case;
// the rest are converted to lowercase. For example, the
// canonical key for "accept-encoding" is "Accept-Encoding".
// MIME header keys are assumed to be ASCII only.
// If s contains a space or invalid header field bytes, it is
// returned without modifications.
func CanonicalMIMEHeaderKey(s string) string {
    // Quick check for canonical encoding.
    upper := true
    for i := 0; i < len(s); i++ {
        c := s[i]
        if !validHeaderFieldByte(c) {
            return s
        }
        if upper && 'a' <= c && c <= 'z' {
            s, _ = canonicalMIMEHeaderKey([]byte(s))
            return s
        }
        if !upper && 'A' <= c && c <= 'Z' {
            s, _ = canonicalMIMEHeaderKey([]byte(s))
            return s
        }
        upper = c == '-'
    }
    return s
}

// validHeaderFieldByte reports whether c is a valid byte in a header
// field name. RFC 7230 says:
//
//    header-field   = field-name ":" OWS field-value OWS
//    field-name     = token
//    tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
//            "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
//    token = 1*tchar
func validHeaderFieldByte(c byte) bool {
    // mask is a 128-bit bitmap with 1s for allowed bytes,
    // so that the byte c can be tested with a shift and an and.
    // If c >= 128, then 1<<c and 1<<(c-64) will both be zero,
    // and this function will return false.
    const mask = 0 |
        (1<<(10)-1)<<'0' |
        (1<<(26)-1)<<'a' |
        (1<<(26)-1)<<'A' |
        1<<'!' |
        1<<'#' |
        1<<'$' |
        1<<'%' |
        1<<'&' |
        1<<'\'' |
        1<<'*' |
        1<<'+' |
        1<<'-' |
        1<<'.' |
        1<<'^' |
        1<<'_' |
        1<<'`' |
        1<<'|' |
        1<<'~'
    return ((uint64(1)<<c)&(mask&(1<<64-1)) |
        (uint64(1)<<(c-64))&(mask>>64)) != 0
}

The CanonicalMIMEHeaderKey is of interest for two reasons. On one hand, it’s responsible for converting the case to a consistent format (i.e., content-type to Content-Type). On the other hand, if invalid bytes are detected, it just sends back the header:

// If s contains a space or invalid header field bytes, it is
// returned without modifications.
func CanonicalMIMEHeaderKey(s string) string {
    ...
        if !validHeaderFieldByte(c) {
            return s
        }
    ...
    return s
}

So, if you remove its uppper-lower-transformation logic, you’re left with a pretty useless function. In this case, that’s not the worst thing that will happen. Nuclei should ideally handle the header’s validity when it validates the YAML.

This also means that a vulnerability with the YAML parsing could lead to invalid header bytes. This is one reason why you should carefully vet any inputs before implicitly trusting them, and is likely implied in Nuclei’s warnings about using unsigned templates. Exploits that try to take advantage of this should be investigated, but it’s not really the scope of this discussion.

With all of that in mind, you can make a quick fix to pkg/authprovider/authx/headers_auth.go and set case-sensitive keys that still conform with MIME standards:

// Apply applies the headers auth strategy to the request
func (s *HeadersAuthStrategy) Apply(req *http.Request) {
    for _, header := range s.Data.Headers {
        req.Header[header.Key] = []string{header.Value}
    }
}

// ApplyOnRR applies the headers auth strategy to the retryable request
func (s *HeadersAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
    for _, header := range s.Data.Headers {
        req.Header[header.Key] = []string{header.Value}
    }
}

Then build it:

docker build -t nuclei:noncanon .

There’s an open bug in Nuclei that prevents dynamic secret scans from prefetching, so you’ll need to target an old version as of now. (There’s a PR for this so hopefully not forever.) At the time of writing, Nuclei 3.4.4 is successfully able to run this and is the version pushed in the latest container, so make sure to:

git checkout v3.4.4

Then make the previous changes, build the container, etc:

$ podman build -t nuclei:3.4.4-nocanon .
...

$ podman run --rm \
    -v $(pwd):/templates \
    --env-file ".env" \
    nuclei:3.4.4-nocanon \
    -config /templates/config.yaml \
    -vv -debug

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.4.4

                projectdiscovery.io

...
[INF] [auth] Dumped HTTP request for https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/check

POST /4ad356ac-e855-4032-8210-903aee8a58f7/check HTTP/1.1
Host: webhook.site
User-Agent: Mozilla/5.0 (Kubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0
Connection: close
Content-Length: 20
Accept: application/json
Accept-Language: en
Content-Type: application/json
Accept-Encoding: gzip

{"hello": "world"}
[DBG] [auth] Dumped HTTP response https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/check

HTTP/1.1 200 OK
Connection: close
Transfer-Encoding: chunked
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length,Content-Range
Cache-Control: no-cache, private
Content-Type: application/json
Date: Sat, 13 Sep 2025 13:18:57 GMT
Server: nginx
X-Request-Id: c05689d9-cedc-4591-8997-4f143f06121b
X-Token-Id: 4ad356ac-e855-4032-8210-903aee8a58f7

{"bar": "SomeBarValue"}
[auth:dsl-1] [http] [info] https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/check ["SomeBarValue"]
[test] Test the case sensitivity of the header keys (@me) [low]
[INF] [test] Dumped HTTP request for https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/test

POST /4ad356ac-e855-4032-8210-903aee8a58f7/test HTTP/1.1
Host: webhook.site
User-Agent: Mozilla/5.0 (Fedora; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0
Connection: close
Content-Length: 22
Accept: application/json
Accept-Language: en
Content-Type: application/json
bAr: SomeBarValue
Accept-Encoding: gzip

{"testing": "world"}
[DBG] [test] Dumped HTTP response https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/test

HTTP/1.1 200 OK
Connection: close
Transfer-Encoding: chunked
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length,Content-Range
Cache-Control: no-cache, private
Content-Type: application/json
Date: Sat, 13 Sep 2025 13:18:58 GMT
Server: nginx

{"bar": "SomeBarValue"}
[test:dsl-1] [http] [low] https://webhook.site/4ad356ac-e855-4032-8210-903aee8a58f7/test
[INF] Scan completed in 428.752346ms. 2 matches found.

The case-sensitive bAr: SomeBarValue is set via the secrets file and sent in successive requests to the API.