How-to › Authenticate and authorize calls

How to add an API key with Guzzle middleware in PHP#

Add an X-API-Key header to every Guzzle request for one API origin with a `mapRequest` middleware, keep it off redirects to other hosts, and test it offline.

Audience
API consumer
Level
intermediate
Topic
Send API keys and bearer tokens
Languages
PHP
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Guzzle client default headers optionA fixed key, one API per client, and no redirects to other hostsThe header follows a redirect to any host, because the only credential headers Guzzle removes on a cross-origin redirect are Authorization and CookieThe API redirects to a file host, or the key rotates while the process runs
Guzzle Middleware::mapRequest on a HandlerStackA key read at runtime, or a stack shared with retry and logging middlewareA push order to get right, and a closure that must return the copy withHeader makesOne script making one request, where a header array reads more plainly
Per-request headersA single call, or a key that differs per tenant on each callEvery call site carries the key, so the one that forgets it gets a 401More 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

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

  1. 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 with or without. The interface goes further: a withHeader that 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

  2. The rule has a history. Guzzle 6.1.0, in September 2015, began removing Authorization when 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 to Cookie, 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.