HTTP header precedence: stop API identity conflicts
HTTP header precedence can split API identity across clients and proxies. Learn how to reject duplicate Authorization and custom headers safely.

A request with two credentials is not a request with backup authentication. It is an ambiguous instruction handed to a chain of software that may interpret it differently at each hop. I have seen teams spend hours blaming an API after a proxy quietly selected one Authorization value and the application selected another. The symptom looks random until someone prints the raw headers at every boundary.
HTTP header precedence needs a rule you can state in one sentence, test on the wire, and enforce before authentication: a request that supplies more than one value for a single security-sensitive credential field fails. Do not choose first. Do not choose last. Do not join values with a comma. Those choices turn incidental implementation behavior into your authentication policy.
This matters beyond Authorization. Many APIs accept X-API-Key, a custom tenant header, a signed-request timestamp, or a forwarded identity field. Once credentials cross clients, load balancers, protocol translators, and application middleware, duplicate handling becomes part of the security boundary.
HTTP does not assign a general winner
HTTP defines field syntax and gives special combination rules for some repeated fields, but it does not say that the first Authorization field wins everywhere or that the last one wins everywhere. A recipient must interpret the received field section according to the field's semantics and its own implementation.
RFC 9110 says that a recipient may combine multiple field lines with the same field name into one comma-separated field value when the field's definition permits a comma-separated list. It also says a recipient must not combine field lines when the field definition does not allow that. That distinction is routinely blurred. “Headers can be merged” is not a rule for every header. It is a rule for fields designed as lists.
Accept is a useful contrast. A client can send repeated Accept lines, and a recipient can generally treat their values as one list of acceptable media types. Authorization carries credentials for a request. It is not a generic list of interchangeable credentials. A comma may also have meaning inside an authentication scheme's syntax. Joining two values can create a string that neither client intended and that different parsers read differently.
Header field names are case-insensitive. These are duplicates:
Authorization: Bearer token-a
authorization: Bearer token-b
A detector that compares original spelling will miss that case. Normalize the name before counting values.
Do not confuse duplicate field lines with multiple authentication challenges in WWW-Authenticate. A server can advertise several acceptable schemes in a response. That is the server offering options. A request with two Authorization values is the client presenting competing credentials. They are different problems and need different handling.
The same warning applies when an API supports both a standard and a custom credential field. An API might deliberately accept Authorization: Bearer ... or X-API-Key: .... Unless the API documentation defines how it resolves both together, sending both produces an identity conflict. Reject it. A fallback that exists only in somebody's middleware is not a contract.
Clients make duplicate headers easier than they look
Whether a client can emit repeated headers depends on the library's request model. Libraries that expose headers as a map often overwrite one value when code assigns the same name twice. Libraries that expose an array or a multi-value collection can send both. Neither behavior proves what the next hop receives.
With curl, repeat -H to request two fields:
curl --http1.1 -v https://api.example.test/orders \
-H 'Authorization: Bearer first' \
-H 'Authorization: Bearer second'
The verbose trace should show two outbound lines similar to this:
> Authorization: Bearer first
> Authorization: Bearer second
That proves only curl's outbound HTTP/1.1 request on that connection. It does not prove the edge, load balancer, or application received both values. Test a controlled endpoint you operate before using this pattern against a production API. The tokens in examples should be disposable, and real bearer tokens must never go into a terminal recording or support ticket.
In JavaScript, the common Headers object has semantics that surprise people. set() replaces the current value. append() adds another value conceptually, but serialization and the header's semantics still matter. A developer may think they sent two headers when the library constructed a comma-joined value instead. That is exactly why authentication tests must inspect raw ingress rather than only inspect an in-memory object.
Node.js server code has its own trap. req.headers commonly exposes normalized names and may present a collapsed view. req.rawHeaders preserves an alternating list of received names and values for HTTP/1.1 requests. A safe diagnostic should count normalized names from the raw representation, then avoid printing secret values:
function duplicateNames(rawHeaders) {
const counts = new Map();
for (let i = 0; i < rawHeaders.length; i += 2) {
const name = rawHeaders[i].toLowerCase();
counts.set(name, (counts.get(name) || 0) + 1);
}
return [...counts.entries()].filter(([, count]) => count > 1);
}
console.log(duplicateNames(req.rawHeaders));
// Example output: [ [ 'authorization', 2 ] ]
Use that as a diagnostic aid, not as permission to hand-parse every header in an application. Your framework or gateway should perform the actual rejection at the earliest trustworthy point. The point is to expose what its convenient header object hid.
Custom headers deserve equal suspicion. A client can accidentally add X-API-Key twice through a default-header layer and a per-request layer. It can also send an Authorization value inherited from a corporate client wrapper while code explicitly adds an API key. The API might appear to work in local testing because the local path lacks the wrapper. Then production receives both credentials and behaves differently.
Proxies can change both the request and the evidence
A reverse proxy is an HTTP recipient and a new HTTP sender. It does not merely carry bytes from a client to an application. It parses the incoming request, applies configuration, may translate protocols, and writes a new request upstream. That gives it ample opportunity to preserve duplicates, discard them, combine them, or create a new credential header.
The dangerous case is split interpretation. Imagine a client sends:
Authorization: Bearer attacker-token
Authorization: Bearer service-token
An edge component keeps the first value for its access check. The upstream library presents the last value to the application. The edge authorizes one identity and the application performs work as another. Even if neither component has a parsing bug, the chain has no single meaning for the request.
A more ordinary failure is less dramatic and more common. A proxy configuration adds an upstream Authorization field for a service credential but forgets to remove an incoming one. The target service chooses one according to behavior nobody documented. A later proxy upgrade, a route migration, or a switch from HTTP/1.1 to HTTP/2 changes which value survives. The team calls it an intermittent authentication outage because they never recorded the duplicate.
Forwarding fields create a related identity problem. X-Forwarded-User, X-Forwarded-Client-Cert, and custom identity headers often travel from a trusted edge to an application. The public-facing edge must remove caller-supplied copies before adding its own. If it forwards untrusted copies, an application that trusts the wrong position in the list can accept a forged identity.
The rule differs slightly by trust boundary:
- At public ingress, reject duplicate authentication fields and remove client-supplied internal identity headers.
- At a trusted gateway, inject the one credential or identity header required upstream after stripping protected caller input.
- At the application, reject duplicates again. Defense at ingress is necessary, but route changes eventually bypass assumptions.
- In logs, record the header name, count, route, and request identifier. Record a credential scheme only if it is safe to do so.
Do not rely on a proxy's default behavior without a test. Defaults differ by product, module, protocol, and configuration. A setting that handles duplicate response headers tells you nothing about duplicate request credentials. Read the documentation for the exact directive or middleware, then send an actual duplicated request through the deployed path.
HTTP/2 and HTTP/3 remove old syntax, not ambiguity
HTTP/2 and HTTP/3 do not send textual header lines on the wire, but they still carry a sequence of header fields. The protocol rules forbid duplicate pseudo-header fields such as :method and require them before regular fields. Those rules help protocol correctness. They do not give Authorization a universal precedence rule.
An HTTP/2 endpoint can receive repeated regular header fields. A library may expose them as separate values, a combined value, or an error depending on the field and its API. HTTP/3 has the same practical concern at the application level. You must test the protocol versions your edge accepts.
Protocol translation is where assumptions rot. A client talks HTTP/2 to a CDN or load balancer. The CDN talks HTTP/1.1 to a gateway. The gateway talks HTTP/2 to a service. Each hop has to translate a header representation. If the first hop collapses a field and the second hop preserves it, the final application cannot reconstruct the original request. That is another reason to make the first trusted recipient reject ambiguity instead of attempting a clever recovery later.
HTTP/2 also treats connection-specific headers differently. Fields such as Connection are prohibited because they describe HTTP/1.1 connection behavior. That has no bearing on credential precedence. Do not copy a protocol-specific header rule and assume it secures a custom authentication header.
Some teams try to solve this with lowercase matching because HTTP/2 requires lowercase field names on the wire. That only fixes one trivial issue. HTTP/1.1 names are case-insensitive too, and a gateway can receive HTTP/1.1 before it receives HTTP/2. Normalize names everywhere.
Build your test matrix around paths, not only protocols. Exercise a direct request to the application in a test environment, the public route, and any internal route used by workers or deployment tooling. For each route, send duplicate Authorization, duplicate custom credential headers, and two competing credential channels. The expected result should be the same explicit client error for every case.
First-wins and last-wins both create an attack surface
“Use the first header” feels conservative because it resembles a parser reading left to right. “Use the last header” feels practical because later configuration should override earlier defaults. Both rules fail because the sender and every intermediary can influence order differently.
First-wins is vulnerable when an attacker can prepend a credential before a trusted component adds its own. Last-wins is vulnerable when an attacker can append a credential after a trusted component has checked the first. The exact exploit depends on routing and trust, but the design error is stable: one component selects a credential while another component sees a different request.
Comma joining is worse than either when it produces a valid-looking string. Consider a custom API key header where the application splits on commas but the gateway compares the whole string. Or consider a bearer parser that accepts the text after the first space and does not reject commas. You have now created two parser languages for a secret-bearing field.
The popular recommendation to “just let the API decide” is wrong when a gateway performs any authentication, rate limiting, tenant routing, or audit labeling before the request reaches the API. The gateway already made a security decision. It must use the same unambiguous identity as the service, or stop the request.
A predictable contract looks like this:
- Normalize every incoming header name.
- Count all occurrences of protected fields before selecting credentials.
- Reject duplicates of each protected field with a generic client error.
- Reject incompatible credential channels when the route accepts only one identity source.
- Remove protected incoming fields before a trusted component injects its own upstream credentials.
Do this before token parsing. If token parsing runs first, one parser may consume a value that the next component would have rejected. Keep the error response boring. Tell the caller that the request contains conflicting authentication headers; do not echo the field values.
There are rare APIs that deliberately define multiple values for a field. If you own such an API, document the grammar, ordering, duplicate behavior, and proxy requirements as part of the authentication contract. “The framework handles it” is not documentation. If you do not own the API, do not invent a precedence scheme around it.
Custom headers need a credential contract, not a naming convention
Teams often treat Authorization as sensitive and custom headers as harmless plumbing. That is backwards. A custom header that selects an API key or tenant is authentication material whether its name begins with X- or not.
Write down which credential channels every route accepts. A route might accept a bearer token from Authorization. Another might accept a webhook signature in a dedicated header plus a timestamp. An internal route might receive an identity header only from a gateway. Those are separate contracts. Avoid a catch-all middleware that accepts whichever credential it happens to find first.
A credential contract needs answers to these questions:
- Can this field appear more than once?
- Can it appear with another credential field?
- Which trusted component may add it?
- Does a proxy strip it before forwarding?
- What error does the API return when it sees a conflict?
For API keys, use one of two clean models. The first accepts exactly one Authorization scheme. The second accepts exactly one named API key header. Do not support both on the same route unless you have a migration need and a documented conflict rule. During a migration, reject requests containing both and give clients a date-free migration message that identifies the accepted replacement. Keeping silent fallback forever creates a permanent diagnostic problem.
Signed webhooks need extra care. Signature headers may legitimately contain structured parameters, and a timestamp may accompany the signature. That does not mean duplicate signature fields are safe. Consult the provider's verification documentation and preserve its expected raw body handling. If it does not define repeated signatures, reject them before verification. Never concatenate them and hope the verification library makes the intended choice.
Internal identity headers are the easiest to get wrong because they are convenient. If an application accepts X-User-Id from the public network and assumes a proxy inserted it, a caller can claim any identity. Bind internal headers to a private network path or authenticated proxy connection, strip them at every public edge, and validate that only your trusted proxy can reach the application port. Header precedence cannot repair a broken network trust boundary.
Test the whole route with deliberate conflicts
Unit tests for a token parser do not test header precedence. You need an integration test that crosses the same components that production crosses. The result you want is boring and stable: every ambiguous request gets rejected before an upstream action happens.
Start with a harmless endpoint under your control. Give it a request identifier and make it return only safe observations: normalized header names, counts, protocol version, and the component that received the request. Do not return header values. Then put it behind the same edge, gateway, and service routing used by the target API.
Run a small conflict set:
Authorization twice, same value
Authorization twice, different values
Authorization plus X-API-Key
X-API-Key twice, same value
A caller-supplied internal identity header plus the gateway's identity
The same-value case matters. Some developers reject only different values because they see equal values as harmless. That creates a parser distinction attackers can probe and leaves an accidental duplication bug invisible. Reject any duplicate protected field. A caller who retries with one field can still authenticate.
Check two outcomes for each case. First, the client gets an explicit 4xx response from the boundary that owns the rule. Second, the upstream system records no action under either identity. A 401 or 403 alone does not prove the route was safe; an upstream system may have processed part of the request before rejecting it.
Then repeat the test against each protocol and path you support. Include the client libraries used by automation, not just curl. A command-line client, a browser fetch wrapper, a CI HTTP library, and an agent runtime can build header collections differently. Keep the test in your deployment suite so a proxy or framework upgrade cannot quietly replace a rejection with a selection rule.
When an incident occurs, capture safe evidence in order. Record the client request construction, edge access decision, gateway outbound headers as names and counts, and application ingress observation. Correlate them with one request identifier. Do not solve a duplicate-header incident by turning on full header logging in production. That is how an authentication bug becomes a credential exposure.
Gateways must own outbound credential injection
An action gateway should keep the agent away from raw credentials and make one component responsible for the final outbound request. Passing a bearer token to the agent and asking it to assemble headers gives it both authority and too many ways to make a malformed request. It also makes audit records harder to interpret when defaults and custom headers collide.
Sallyport executes HTTP calls with credentials injected from its encrypted vault, rather than exposing the secret to the agent. That boundary is useful only if the outbound request builder treats credential headers as protected fields, not as suggestions that agent-supplied headers may override.
For a protected outbound header, the gateway should select the saved credential configuration, remove any caller-supplied instance of that field case-insensitively, and add exactly one final value. It should apply the same rule to custom credential headers. If the target request needs an agent-controlled header with the same name, the configuration is wrong or the target API needs a separate route. Do not make a per-request exception that silently changes identity.
There is an important distinction here. Removing an agent-provided Authorization header before adding the configured one is appropriate at the trusted outbound boundary. Accepting two headers and hoping the target sorts them out is not. The first action establishes a single credential source. The second exports ambiguity.
A gateway also needs to protect against competing channels. Suppose a saved connection injects Authorization: Bearer ..., while the agent request includes X-API-Key. The target may accept either. The safest default is to reject the request as conflicting credentials unless the connection definition explicitly permits that combination and documents why. A generic header allowlist cannot answer this because the meaning belongs to the target API.
Audit records should tell an operator that the gateway used a named connection or credential label, which host received the request, the method, the path, and whether a human approval occurred. They should not store the Authorization value or secret custom header. Sallyport's Activity journal is designed around individual calls, but a journal does not fix an ambiguous request after the fact. The request builder must remove the ambiguity before it leaves the machine.
Logging reveals precedence failures without leaking tokens
Duplicate header bugs survive because teams either log too little to see the route change or log too much and create a second incident. You can log enough to diagnose the issue without retaining credentials.
For every rejected conflict, record a normalized list of protected header names and their counts. Add the route name, protocol, receiving component, request identifier, and a reason code such as duplicate_authorization or conflicting_credential_channels. If your operations process allows it, record a credential configuration identifier that cannot be used to retrieve the secret.
Never log raw bearer tokens, API keys, basic authentication values, signed webhook payloads, cookies, or full Authorization lines. Redaction after string formatting is unreliable. A library may throw an exception that includes the original request, or a debug logger may run before your redactor. Build log records from safe fields instead of taking a request dump and trying to clean it later.
Hashing a token is not automatically safe. A stable hash can let anyone with access to logs correlate use of the same credential and may be vulnerable to guessing when the secret has low entropy. Use a non-secret credential ID from your vault or configuration if you need correlation. If you do not have one, record that an unnamed credential channel was present and fix the configuration model.
Treat missing evidence as a deployment failure. If an edge rejects a duplicate but the audit trail cannot show which edge made that decision, responders will waste time comparing application logs that never saw the request. Conversely, if the application rejects it but the edge allowed it through, you have found a place to tighten enforcement.
The practical first fix is small: enumerate the credential-bearing headers on one public route, reject repeats and conflicts at ingress, and prove with an integration test that no upstream call occurs. Then apply the same contract to every proxy path and outbound client. Header order should never decide who an API thinks is calling.
FAQ
Which HTTP header wins when the same header appears twice?
HTTP does not define a universal winner for duplicate field names. A recipient may combine some fields, reject the request, keep the first value, keep the last value, or pass both onward. Security-sensitive fields need an explicit rule at every boundary.
Should an API accept two Authorization headers?
Treat duplicate Authorization headers as a request failure. Picking first or last makes the accepted credential depend on client behavior and proxy rewrites, which is a bad security contract. Reject before authentication and record the duplicate safely.
Can HTTP/2 create duplicate headers?
They can, and that is a serious source of confusion. HTTP/2 and HTTP/3 carry header fields as structured header blocks, while an HTTP/1.1 hop can serialize them differently. A gateway must preserve its duplicate-rejection rule across protocol translation.
Do reverse proxies merge duplicate HTTP headers?
A reverse proxy may retain both fields, collapse them, remove one, or add its own credentials. Its configuration and module order decide the result, not the browser or API client. Test the exact deployed route rather than assuming the proxy is transparent.
Can I send Authorization and X-API-Key together?
Use a distinct header only if the service explicitly documents it, such as X-API-Key or a vendor-specific credential header. Do not send it alongside Authorization as a fallback. The server must reject requests that present competing credentials unless its documentation defines a safe, deterministic choice.
Are Authorization and authorization different headers?
No. HTTP header names are case-insensitive, so authorization, Authorization, and AUTHORIZATION name the same field. A duplicate detector must compare normalized field names, not their original spelling.
Can I combine duplicate Authorization headers with a comma?
Do not use a comma join for Authorization. Commas can occur inside authentication syntax and an authentication header is not generally a list field. Reject the request or remove the unwanted field at a trusted boundary under a documented rule.
How do I debug duplicate request headers safely?
Inspect the raw request at every hop: the client, edge proxy, application gateway, and application. Log header names and safe metadata such as lengths or credential scheme, never bearer tokens. A local listener is useful for proving what the client actually emitted.
Is the first Authorization header always used?
Avoid relying on request order. Different libraries preserve, reorder, coalesce, or overwrite repeated fields, and intermediaries can change the wire representation. A safe API behaves identically by rejecting all duplicates regardless of order.
Can custom authentication headers be duplicated too?
Do not assume it will appear unchanged. Some clients can send repeated custom headers, while others overwrite a map entry, and intermediaries may apply their own normalization. Capture the wire request in an isolated test and keep that test in your release checks.