Your PHP service calls a metering API through Guzzle from a dozen classes. Some build their client
with a headers option, some pass the key per request, and the one written last forgets it, so
that call gets a 401. When the API answers an export with a redirect to its file host, Guzzle
forwards your X-API-Key header to that host as well.
What you get
You will end up with one Guzzle client whose handler stack adds the key to requests for the API’s origin and to nothing else, with tests that record every header sent. This is for you if you call a key-authenticated API from PHP.
Short answer
Push a Middleware::mapRequest closure onto a HandlerStack that returns
$request->withHeader('X-API-Key', $key) for the API’s origin and strips the header for any other
origin. Build one Client from that stack and inject it wherever the API is called. Push it after
the retry middleware so a rotated key is read on every attempt, and test it with MockHandler and
a history middleware.
You will need
PHP 8.1 or later, Guzzle 7 or 8 installed with
Composer, and the key. Verified 2026-10-15 against PHP 8.3.6 and Guzzle 8.2.0. Whether the key
belongs in a header or a query parameter is the API’s decision and a separate question; this page
assumes a header named X-API-Key.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Guzzle client default headers option | A fixed key, one API per client, and no redirects to other hosts | The header follows a redirect to any host, because the only credential headers Guzzle removes on a cross-origin redirect are Authorization and Cookie | The API redirects to a file host, or the key rotates while the process runs |
| Guzzle Middleware::mapRequest on a HandlerStack | A key read at runtime, or a stack shared with retry and logging middleware | A push order to get right, and a closure that must return the copy withHeader makes | One script making one request, where a header array reads more plainly |
| Per-request headers | A single call, or a key that differs per tenant on each call | Every call site carries the key, so the one that forgets it gets a 401 | More than a few call sites share one key |
The headers option is the shortest route and fine for a fixed key on an API that never
redirects, but it sends the key to whatever host a redirect names. Per-request headers make that
choice at every call site, which is where the forgotten key comes from. The middleware costs a
closure and an ordering decision, and in exchange it reads the key per request and decides per
origin.
Write the middleware
The middleware is a mapRequest closure that checks the request’s origin, reads the key, and
returns a modified copy of the request.
function apiKey(string|\Closure $key, string $origin, string $header = 'X-API-Key'): callable
{
$uri = new Uri($origin);
if (!in_array(strtolower($uri->getScheme()), ['http', 'https'], true) || $uri->getHost() === '') {
// A bare host such as 'api.meterco.example' matches no request, so
// every call would go out without the key and come back 401.
throw new \InvalidArgumentException("API origin must be an absolute http(s) URL, got '{$origin}'");
}
$origin = originOf($uri);
return Middleware::mapRequest(
function (RequestInterface $request) use ($key, $origin, $header): RequestInterface {
if (originOf($request->getUri()) !== $origin) {
// Any other host, a redirect target included, gets no key,
// even one a caller set by hand.
return $request->withoutHeader($header);
}
$value = is_string($key) ? $key : $key();
if (!is_string($value) || $value === '') {
throw new \UnexpectedValueException("no API key available for {$origin}");
}
// PSR-7 requests are immutable: withHeader returns a copy, and the
// copy is what has to go down the stack.
return $request->withHeader($header, $value);
}
);
}
An origin is scheme, host and port, so http://api.meterco.example is a different origin from
the https one and gets no key. Passing a closure instead of a string makes the key a runtime
read: the closure runs once per request that reaches the middleware. An empty key throws before
anything is sent, which turns a missing environment variable into an exception at the call site
rather than a 401 from the server. An origin with no scheme throws when the stack is built, for
the same reason: a bare host matches no request, so every call would go out without the key.
Build one client from the stack
The client is built once, from a stack that holds the default middleware, the retry middleware and the key.
function stack(string|\Closure $key, ?callable $handler = null, ?callable $delay = null): HandlerStack
{
$stack = HandlerStack::create($handler);
$stack->push(Middleware::retry(retryDecider(2), $delay), 'retry');
$stack->push(apiKey($key, API), 'api_key');
return $stack;
}
/** Build one client and inject it wherever the API is called. */
function client(HandlerStack $stack): Client
{
return new Client(['base_uri' => API . '/v1/', 'handler' => $stack, 'timeout' => 10]);
}
Pass that client to the constructor of every class that calls the API. A class that builds its own client is a call site the key can miss.
Order is the decision that matters here. The
HandlerStack documentation
shows three pushed middleware echoing ABC: the first pushed runs first, so it wraps everything
pushed after it. In Guzzle 8.2.0,
HandlerStack::create() has
already pushed http_errors, allow_redirects, auth, cookies, and prepare_body;
Guzzle 7.15.5 pushes the
same list without auth. A key pushed afterwards sits inside the redirect middleware
and sees every hop of a redirect. Pushed after retry, it sees every attempt.
Check it worked
A MockHandler answers from a queue without
a network, and a history middleware pushed last records each request exactly as the handler
receives it. The tests assert on that record, not on the response.
check('a redirect to another host does not carry the key', function () {
$api = recorded([
new Response(302, ['Location' => 'https://files.example.net/export.csv']),
new Response(200),
], log: $log);
$api->get('exports/latest');
expect(keys($log) === ['mk_test_alpha', ''], 'keys were ' . json_encode(keys($log)));
});
php test.php
ok every request to the API origin carries the key
ok a request to another origin gets no key, even one set by hand
ok plain http to the same host is another origin
ok a redirect to another host does not carry the key
ok a key closure is read again on every retry attempt
ok an empty key fails before anything is sent
ok an origin with no scheme is refused when the stack is built
ok a POST that fails with 503 is not retried
ok pitfall: a dropped withHeader result sends no key
ok pitfall: the headers option follows a redirect to another host
10 cases, 0 failures
The last two cases pin the failures below, so a Guzzle upgrade that changes either one fails the
suite. Removing the origin check from apiKey fails three cases, and dropping the returned copy
fails three more.
When it goes wrong
The demonstration sends the same requests through each variant and prints the key header each request carried when it reached the handler.
php demo.php
the middleware, two calls
GET https://api.meterco.example/v1/meters mk_test_alpha
POST https://api.meterco.example/v1/readings mk_test_alpha
withHeader called, result dropped
GET https://api.meterco.example/v1/meters (none)
headers option, redirect to another host (X-API-Key)
GET https://api.meterco.example/v1/exports/latest mk_test_alpha
GET https://files.example.net/export.csv mk_test_alpha
headers option, redirect to another host (Authorization)
GET https://api.meterco.example/v1/exports/latest Bearer mk_test_alpha
GET https://files.example.net/export.csv (none)
the middleware, redirect to another host
GET https://api.meterco.example/v1/exports/latest mk_test_alpha
GET https://files.example.net/export.csv (none)
pushed before retry attempts 3, key reads 1, keys sent: mk_test_alpha mk_test_alpha mk_test_alpha
pushed after retry attempts 3, key reads 3, keys sent: mk_test_alpha mk_test_bravo mk_test_bravo
Every request goes out without a key. The closure called $request->withHeader() and returned
$request. PSR-7 messages are immutable, so the copy
carrying the header was discarded.1 Return the result of withHeader.
Your key appears in a file host’s logs. The headers option set it on the first request, and
the only credential headers Guzzle’s redirect middleware removes when the origin changes are
Authorization and Cookie, as its source
shows.2 Move the key into the middleware and check the origin there.
A rotated key still fails on retries. The key middleware was pushed before retry, so it ran once
and every retry reused that first request. Push it after retry, and pass a closure, so each
attempt reads the current key, as the last two lines show.
When not to do this
Do not write middleware for a script that makes one request with a key that never changes. A
headers array on that one call is clearer, provided the API does not redirect it.
Do not push the key middleware with unshift. That puts it outside allow_redirects and
retry, which brings back both failures in the preceding section: it runs once per call, and a
redirect target receives whatever it set.
Do not log the request inside the stack without redacting the header. A logging middleware pushed
after the key sees it in plain text, and Guzzle’s
MessageFormatter
prints every request header through its {req_headers} and DEBUG templates.
Do not use this to send one key to several APIs. One middleware per origin, each with its own key,
keeps a leaked key’s reach to one service. If the API accepts Authorization: Bearer, prefer it
to a custom header, because Guzzle’s redirect handling and the
Fetch Standard both remove that header on a
cross-origin redirect and know nothing about X-API-Key.3
Related how-tos
Last verified
Verified 2026-10-15 against PHP 8.3.6 and Guzzle 8.2.0, with Guzzle’s dependencies committed under
vendor/ in the sample directory. Both output blocks are what the preceding command printed. The
same demonstration and tests were also run against Guzzle 7.15.5 and printed the same lines.
Footnotes
-
The PSR-7 meta document spends a section on why messages are value objects. A change to any part of a message makes, in effect, a new message, so the methods that change one return a new instance. The document says their names generally start with
withorwithout. The interface goes further: awithHeaderthat changed the object in place would break a MUST. In the bug on this page the library behaves exactly as specified: it returns the copy, and the garbage collector reclaims it unread. ↩︎ Back to text -
The rule has a history. Guzzle 6.1.0, in September 2015, began removing
Authorizationwhen a redirect changed the host. Version 7.4.4, in June 2022, added the case of a downgrade to plain HTTP and extended the same treatment toCookie, both recorded in the changelog as fixes. Each entry names a header. A header the library has never heard of cannot be on the list, which is the argument for the origin check living in your code. ↩︎ Back to text -
The Fetch Standard’s redirect algorithm deletes every “CORS non-wildcard request-header name” when the origin changes, and the definition of that category is a list of one:
Authorization. The standard adds a note restating it in a single sentence, in case a list of one had seemed like an oversight. ↩︎ Back to text