Are custom authentication headers safe to inject?
Custom authentication headers need byte-level validation before a gateway injects them. Reject line breaks, Unicode traps, and duplicate fields.

A gateway that injects credentials must treat an HTTP header as protocol syntax, not a convenient string field. If an agent, a configuration screen, a vault import, or a template can put an unexpected byte into that field, the gateway can send a request with different meaning than the approval screen, audit record, or destination service suggests.
The safe rule is narrow: reserve each authentication field for the gateway, validate the final bytes immediately before request construction, and reject malformed input rather than cleaning it up. This catches line breaks, hidden controls, Unicode lookalikes, and duplicate field names before any credential leaves local storage.
The injection boundary needs a byte contract
A custom authentication header should have a contract that is stricter than generic HTTP parsing. Generic parsing must support a large and messy internet. A gateway that moves a secret from a vault into a request has a smaller job, so it should accept less.
Start by separating four things that teams often collapse into one "header value":
- The configured field name, such as
X-Api-Key. - The secret bytes stored for that field.
- The agent supplied request headers for a particular action.
- The final outbound header list handed to the HTTP client.
Each has a different owner. An administrator or developer configures the name. The vault owns the secret. The agent can request an HTTP operation and may supply ordinary application headers. The gateway alone assembles the final protected field.
That distinction matters because an agent should never receive a secret merely to concatenate it with a header label. It also prevents a subtler error: allowing the agent to provide x-api-key while the gateway later adds X-Api-Key. HTTP field names are case insensitive, so those are competing instances of the same field, not two unrelated headers. RFC 9110 defines field names as case insensitive and describes fields that expect one member as singleton fields.
For a credential header, make the byte contract explicit:
- The name uses only the HTTP token character set and is stored in a canonical lowercase comparison form.
- The value is a nonempty sequence of printable ASCII bytes from
0x21through0x7e. - The value contains no space, tab, CR, LF, NUL, or any other control byte.
- The gateway adds exactly one instance of the protected name.
- An agent request that names the protected field fails. It does not get silently edited.
This policy intentionally rejects some legal generic field content. That is the point. An API key, bearer token, or vendor credential should not need a tab, a leading space, or an accented character to travel safely in a custom header. If a provider has a credential format that needs arbitrary bytes, encode it before storage with the provider's documented transport representation. Base64url is common when a protocol calls for a printable token. Do not invent a lossy conversion at the gateway.
The most tempting alternative is to accept arbitrary text, trim it, replace controls with spaces, and rely on the HTTP library to reject the rest. That creates several representations of one credential. The UI may show one string, the log may contain another, and the library may transmit a third. A security control should decide once whether an action is valid. It should not edit the action into something else.
CR, LF, and NUL must fail before request construction
Reject carriage return (0x0d), line feed (0x0a), and NUL (0x00) anywhere in a protected header value. Reject a lone CR or lone LF just as firmly as the familiar CRLF sequence.
HTTP/1.1 uses CRLF to delimit field lines. RFC 9112 also permits recipients to recognize a bare LF in some circumstances, a tolerance that is exactly why an outbound gateway cannot assume every downstream component will react the same way. It says senders must not create bare CR within protocol elements, and it deprecates historical folded field lines.
RFC 9110 is even plainer about field values: CR, LF, and NUL are invalid and dangerous because implementations can parse them differently. It says recipients must reject them or replace them with spaces before further processing. Replacing is a receiver recovery rule, not a good gateway design. A gateway knows it is creating a new request, so it should reject rather than inherit ambiguity.
Consider a value that reaches the request builder like this:
team-secret\r\nX-Approval: bypassed
A correct validator sees two forbidden bytes and denies the action. A weak validator that only searches for the literal characters \\r\\n sees harmless backslashes. A weak validator that runs before template expansion may inspect ${TOKEN} and never inspect the expanded value. A weak validator that replaces the line break with a space turns an obvious denial into a request carrying a malformed, altered credential.
The only useful check operates on the final byte sequence, after decoding and interpolation, before the HTTP library accepts headers. It should produce a result shaped like this:
reject header value: field=x-api-key reason=forbidden-byte byte=0x0a offset=11
That message is for the local action record, not for an external caller. It identifies the configured field and the bad byte without copying the value. Avoid recording a quoted, JSON escaped, base64 encoded, or normalized form of the secret. Those forms are still secret material, and they tend to survive longer in logs than the request itself.
Do not restrict this to HTTP/1.1. HTTP/2 carries fields in compressed blocks rather than text lines, but RFC 9113 still forbids NUL, LF, and CR at every position in a field value. It specifically warns that unvalidated fields can cause request smuggling when an intermediary translates them to HTTP/1.1. HTTP/3 makes the same point about fields that later cross into a text based protocol.
Header names need ASCII ownership rules
Reject non-ASCII header names. Do not normalize them, transliterate them, or attempt to decide that a lookalike is "close enough" to a protected name.
HTTP field names are tokens. The generic rule excludes spaces, controls, delimiters, and non-ASCII characters. HTTP/2 adds a transport rule that names must be lowercase and rejects non-visible ASCII, uppercase letters, and bytes above 0x7f. A gateway can use an even simpler rule: validate a configured name as an ASCII HTTP token, lowercase it with ASCII rules, then retain its original spelling only for display.
The comparison operation must be byte based. If the protected name is x-api-key, the following agent supplied names must all collide with it after ASCII lowercasing:
X-Api-Key
x-api-key
X-API-KEY
The following must fail name validation outright, not be repaired:
x-api‐key // Unicode hyphen
x-api-kеy // Cyrillic e
x-api-key\n
x api key
The displayed comment in that fixture matters. A person may not see the difference between an ASCII hyphen and a Unicode hyphen, or an ASCII e and a Cyrillic е. A parser should not need to become good at typography. Refuse all non-ASCII header names and the ambiguity disappears.
Ownership is separate from syntax. A name can be syntactically valid and still be forbidden because the gateway owns it. Keep a protected-name set in lowercase ASCII. Check every agent supplied request header against that set before combining any header collections. Then construct the outbound list from scratch:
ordinary agent headers that passed validation
+ gateway owned credential header
+ gateway owned request metadata, if any
Do not begin with the agent's list and overwrite selected entries. Some HTTP libraries preserve repeated values, some coalesce values, and some expose headers as a map that loses the original duplication. Once a protected field has entered the wrong collection, it is already too late to trust a casual overwrite.
Unicode normalization is not a repair operation
Do not Unicode normalize authentication values at injection time. Normalization has a legitimate role in systems that define text equality, but a secret is usually an opaque byte sequence whose issuer decides its meaning.
Unicode Normalization Forms, specified by Unicode Standard Annex #15, describe transformations between canonically or compatibly equivalent text representations. That does not make them safe transformations for a credential. The two strings café and café can look identical while using different code point sequences. A provider might reject one, accept one, hash one, or treat them as distinct secret values. A gateway cannot guess which behavior the provider chose.
There are two separate normalization questions:
Normalize identity fields only when their protocol defines it
A product may define how it compares user names, display labels, or application metadata. Apply that rule at the boundary where the product owns that meaning, and retain a clear record of the chosen representation. Do not reuse the rule for HTTP credential injection simply because both inputs arrived as strings.
Header names are easier. They are protocol identifiers, not user prose. Reject non-ASCII input and use ASCII lowercase for comparison. NFC, NFD, NFKC, NFKD, Unicode case folding, and confusable skeletons have no place in that decision.
Never use confusable matching to merge protected headers
Unicode Technical Standard #39 provides confusable detection data for security reviews. It explicitly says confusable skeleton mappings should not become a normalization of identifiers. That warning is sound: a mapping that helps flag a suspicious label is not a safe rule for silently changing protocol input.
Use confusable detection in a configuration UI if you want to warn that a human typed a strange label. Do not use it in the request path. The request path should say: this field name is ASCII and reserved, or it is invalid. This value is printable ASCII and exact, or it is invalid.
Testing Unicode normalization is still necessary, even under an ASCII credential policy. It exposes hidden conversions in interfaces and storage layers. Feed a decomposed value, a composed equivalent, a nonbreaking space, a zero width character, and a right to left control through each input route. The correct result for a custom authentication value is rejection with the same generic category: non-ASCII byte or character. The result must not depend on whether the text came from a settings screen, an imported file, or an agent argument.
Duplicate fields are an authorization failure
For a protected authentication name, a duplicate field should deny the action even if the two values match. One field owner is simpler to audit, simpler to approve, and far less dependent on destination behavior.
People often argue that duplicates are harmless when the values are identical. They are thinking about data cleanup, not authorization. A duplicate can appear before or after a proxy reorders fields. A destination might select the first instance, the last instance, concatenate instances, reject the request, or apply a field specific rule. An intermediary may make a different choice. The action approval then no longer describes one unambiguous request.
Do not solve this by saying that Authorization is special while every X-... header is casual. A vendor can define its custom credential field as single valued, and most authentication schemes do. The gateway should maintain an explicit list of protected names per connection or credential binding. The list may include authorization, x-api-key, x-api-token, or a vendor field configured by the developer. It should not depend on a naming convention.
Duplicate detection needs to happen before the HTTP client library turns a header list into its own representation. Many convenience APIs use a map from a case insensitive name to a string slice. That preserves duplicates if used carefully, but it can obscure ordering and source ownership. Other APIs offer a set method that replaces one value and an add method that appends another. Those operations are fine only after the gateway has rejected agent attempts to touch protected names.
Use this decision table for every agent supplied header:
| Condition | Gateway result |
|---|---|
| Invalid field name | Deny the action |
| Protected name in any ASCII case | Deny the action |
| Duplicate ordinary name that the destination protocol allows | Preserve only through an explicit per-name rule |
| Duplicate ordinary name with unknown semantics | Deny the action or require a connection specific rule |
| Gateway credential field | Add exactly once after agent headers pass validation |
The third row is intentionally narrow. Accept may have list semantics. A custom application field may not. If the gateway does not know whether repetition changes meaning, it should not fabricate a rule. This is where a generic "pass through all headers" feature quietly becomes a way to bypass the human's intent.
Validate each transformation boundary, then validate the final result
One early validation check is not enough because request values often change shape after the first check. Every transformation boundary that can create, decode, join, or substitute bytes needs a local invariant, and the final request builder needs the decisive validation.
A practical flow has five boundaries:
- When someone configures the header name, validate its ASCII token syntax and reserve its lowercase form.
- When a secret enters storage, validate the credential contract and store its exact approved bytes.
- When an agent supplies an operation, validate ordinary request header names and values, then reject protected names.
- After templates, file reads, environment expansion, and structured request decoding, validate the values again because those processes may have created new bytes.
- Immediately before the HTTP library call, validate the complete field list and assert that every protected name occurs once.
The final check has authority because it sees the actual outbound object. Earlier checks exist to give good error messages and prevent bad state from entering the system. They do not replace it.
This is also why validation belongs in the gateway core rather than in an MCP tool description, a prompt, or an agent side helper. Those places can describe an intended request. They cannot guarantee what reaches the socket. The gate that owns credential injection must own the last byte check.
Sallyport follows the useful part of this boundary: the app performs HTTP credential injection itself, while the agent receives the result rather than the secret. For a custom header credential, the action layer should still reject an agent supplied copy of the reserved field before it reaches that injection point.
Keep validation errors deterministic. A request that contains a CR at byte 8 and a duplicate protected header should report the earliest failure according to a fixed ordering, such as invalid name, protected-name collision, invalid value, then final cardinality. Determinism makes tests reliable and prevents an attacker from using error differences to learn more about stored credentials.
Make the validator small enough to audit
A short validator with a hard contract is safer than a general sanitizer with a long list of exceptions. The following Go example deliberately permits only printable ASCII bytes in a custom authentication value. It does not trim, normalize, decode, or rewrite input.
package authheader
import (
"fmt"
"strings"
)
func canonicalName(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("empty field name")
}
for i := 0; i < len(name); i++ {
b := name[i]
ok := b == '!' || b == '#' || b == '$' || b == '%' || b == '&' ||
b == '\'' || b == '*' || b == '+' || b == '-' || b == '.' ||
b == '^' || b == '_' || b == '`' || b == '|' || b == '~' ||
('0' <= b && b <= '9') || ('A' <= b && b <= 'Z') ||
('a' <= b && b <= 'z')
if !ok {
return "", fmt.Errorf("invalid field-name byte 0x%02x at offset %d", b, i)
}
}
return strings.ToLower(name), nil
}
func validateCredentialValue(value string) error {
if len(value) == 0 {
return fmt.Errorf("empty credential value")
}
for i := 0; i < len(value); i++ {
b := value[i]
if b < 0x21 || b > 0x7e {
return fmt.Errorf("invalid credential byte 0x%02x at offset %d", b, i)
}
}
return nil
}
func rejectProtected(headers [][2]string, protected map[string]struct{}) error {
for _, h := range headers {
name, err := canonicalName(h[0])
if err != nil {
return err
}
if _, found := protected[name]; found {
return fmt.Errorf("agent supplied protected field %q", name)
}
}
return nil
}
This example accepts : in a value because printable ASCII includes it. That is fine for many API tokens. If a destination accepts only a documented subset, enforce that provider rule in a connection specific validator. Do not make a global gateway policy guess that a colon, slash, or equals sign is suspicious.
The use of string in Go does not mean the validator has become Unicode aware. Indexing a Go string yields bytes. A multibyte UTF-8 sequence contains bytes above 0x7e, so this contract rejects it cleanly. If your runtime stores Unicode scalar values rather than byte strings, encode to the exact outbound byte representation first, then run the byte validation.
The caller must not expose validateCredentialValue as a cleanup helper. Its only valid outcomes are approval of unchanged bytes or an error. Any method named sanitizeHeader, cleanHeader, or normalizeToken deserves suspicion during review because it invites callers to transform a secret while believing they are protecting it.
A test matrix needs hostile representations, not just hostile strings
A test suite should prove that every route into the outbound request reaches the same final validator. It should not only call the validator directly with obvious CRLF input.
Use table driven unit tests for raw values first:
cases := []struct {
name string
value string
want bool
}{
{"ordinary token", "mF_9.B5f-4.1JqM", true},
{"carriage return", "abc\rdef", false},
{"line feed", "abc\ndef", false},
{"crlf", "abc\r\ndef", false},
{"nul", "abc\x00def", false},
{"leading space", " abc", false},
{"trailing tab", "abc\t", false},
{"composed Unicode", "caf\u00e9", false},
{"decomposed Unicode", "cafe\u0301", false},
}
Then test the full action path with fixtures that look mundane to application code:
- A JSON request where a newline arrives through
\ndecoding. - A configuration import where a file ends with a newline after the token.
- A template result where a missing variable becomes an empty string.
- An agent request with both
X-Api-Keyandx-api-key. - An agent request that uses a non-ASCII lookalike in the name and must fail name syntax rather than bypass protected-name matching.
The file ending case catches a real habit. Developers copy a token into a text file, add a final newline without noticing, and use a command substitution or importer that preserves it. A shell command may remove trailing newlines in one path while a file reader preserves them in another. The correct gateway behavior is consistent: it does not trim. It asks the operator to fix the stored credential.
Add property tests around the final builder. Generate short byte strings containing every control byte, bytes above 0x7f, and printable ASCII. Assert that only nonempty strings entirely in the allowed range pass. Generate case variants of each protected field name and assert that every ASCII case variant denies agent ownership. Those tests find errors such as checking only \r\n together, forgetting lone LF, or lowercasing after looking up the protected set.
Finally, use an integration listener that captures the headers your HTTP library actually receives. It should never see a malformed protected value because the gateway should deny first. The listener verifies construction, not safety policy. If that integration test discovers a second protected field, treat it as a gateway defect even when the destination server would have rejected the request.
A permissive fallback hides the bug you need to fix
Replacing forbidden bytes with spaces, trimming whitespace, or keeping the "last" duplicate is popular because it makes demos work. It also makes the source of an outbound credential hard to reconstruct.
Walk through the trim case. An operator imports token-42\n from a file. One code path trims it and sends token-42. Another code path stores the raw value and later rejects it. The operator now sees an action work in a local test but fail in the gateway. Someone will eventually add trimming to the gateway to remove the inconsistency. Months later, a template emits token-42\r\nX-Role: admin; the same trimming code may only remove the final newline and leave the internal CRLF untouched. The "helpful" behavior has obscured both the source error and the security boundary.
The right repair is boring. Define one accepted credential format, reject all other values, and make import tools show why the bytes fail. A configuration screen can display trailing LF at offset 8 without revealing the token. A command line importer can print the same category and exit nonzero. The user fixes the source rather than teaching every later layer to guess.
Audit records should distinguish an authorization denial from a transport failure. Record that the agent process requested an action, that the action was denied before dispatch, which configured credential binding was involved, and the validation category. Do not call the destination and do not create a synthetic HTTP response that looks like a remote rejection. There was no remote request.
Sallyport's separate session and activity records give this kind of denial a useful place to live: the session shows which agent run attempted it, while the action record can show that local validation stopped dispatch. The record should remain useful without preserving the rejected secret or an escaped approximation of it.
The rule is strict because the gateway owns a secret
A normal HTTP client can tolerate broad input because it is often sending data the caller already possesses. A credential gateway has a different responsibility. It decides whether an agent may cause a secret backed action, and it must make that decision on a request whose syntax cannot change underneath it.
Reject controls and non-ASCII in custom authentication values. Reserve authentication names and compare them with ASCII lowercase rules. Refuse duplicate protected fields. Never normalize or trim a credential on the way out. Validate every input path, then validate the final header list one last time.
If a provider's authentication format cannot fit that contract, document the provider specific encoding and implement it before the secret reaches the request builder. Do not loosen the gateway into accepting ambiguous text because one integration arrived with a messy token. The first malformed value should stop at the gate, with a denial that explains the byte problem and leaves the credential unseen.
FAQ
Should a gateway reject both CRLF and a lone newline in an HTTP header value?
Reject both. A carriage return and line feed can change how an HTTP/1.1 recipient sees field boundaries, and different recipients have historically accepted different line endings. Do not repair either character in a credential header. Refuse the action before constructing the outbound request.
Do I need to block NUL bytes in custom authentication headers?
NUL has no legitimate place in an authentication header value and HTTP treats it as invalid. Reject it alongside CR and LF, then record the byte offset and field role in the audit event without recording the secret itself.
Should API keys be Unicode normalized before adding them to a header?
Do not normalize a credential at injection time. Unicode normalization can make two strings compare equal while changing the bytes a provider expects. Store and inject the exact approved bytes, or require an ASCII transport encoding such as base64url before the secret enters the vault.
How should I compare duplicate HTTP header names safely?
Treat header field names as ASCII protocol tokens, then compare their lowercase ASCII form. Do not apply Unicode case folding or NFKC to field names. A non-ASCII name should fail validation rather than become a close-looking version of a protected field.
Should a gateway drop or reject a duplicate Authorization header?
For an injected authentication field, reject the request. Silently removing the agent supplied copy hides an authorization mistake and can leave different layers with different ideas about what happened. A protected field should have one owner, the gateway.
Is allowing tabs or Unicode in an API key header value safe?
A generic HTTP parser may permit a wider range of field content than your credential contract should accept. For custom authentication, an ASCII visible-character policy with no leading or trailing whitespace is usually easier to test and safer across HTTP versions and intermediaries.
What test cases catch HTTP header injection bugs?
Test raw CR, raw LF, CRLF, NUL, leading and trailing spaces, tabs, non-ASCII bytes, decomposed Unicode, composed Unicode, and duplicate names with different ASCII case. Run the same cases through every configuration, vault, template, and request construction path.
Where should header validation happen in an agent gateway?
The value must be checked immediately before the HTTP library receives it, because earlier checks can be bypassed by decoding, interpolation, or serialization changes. Validate the configured header name when it is saved, but validate the final byte sequence for every outbound action.
Are line breaks in headers only dangerous for HTTP/1.1?
HTTP/2 removes the textual CRLF framing used by HTTP/1.1, but it still forbids CR, LF, and NUL in field values. A gateway can also cross protocol boundaries through a proxy, so validation cannot depend on the current transport version.
What should an audit log record when a header value fails validation?
Log the action as denied, the protected field's configured name, the rejection category, and the position of the bad byte. Do not log the submitted header value, a normalized copy, or an escaped representation of the credential. Escape notation often becomes a second path for a secret to leak.