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

Source: https://voxgig.com/howto/api-key-guzzle-middleware

- Audience: api-consumer
- Level: intermediate
- Languages: php
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://packagist.org/packages/guzzlehttp/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](https://docs.guzzlephp.org/en/stable/request-options.html#headers) | 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](https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html) | 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](https://docs.guzzlephp.org/en/stable/request-options.html#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.

```php title="ApiKey.php"
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.

```php title="Client.php"
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](https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#handlerstack)
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()`](https://github.com/guzzle/guzzle/blob/8.2.0/src/HandlerStack.php) has
already pushed `http_errors`, `allow_redirects`, `auth`, `cookies`, and `prepare_body`;
[Guzzle 7.15.5](https://github.com/guzzle/guzzle/blob/7.15.5/src/HandlerStack.php) 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`](https://docs.guzzlephp.org/en/stable/testing.html) 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.

```php title="test.php"
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)));
});
```

```bash
php test.php
```

```text output
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.

```bash
php demo.php
```

```text output
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](https://www.php-fig.org/psr/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](https://github.com/guzzle/guzzle/blob/8.2.0/src/RedirectMiddleware.php)
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`](https://github.com/guzzle/guzzle/blob/8.2.0/src/MessageFormatter.php)
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](https://fetch.spec.whatwg.org/#http-redirect-fetch) both remove that header on a
cross-origin redirect and know nothing about `X-API-Key`.[^3]

## Related how-tos

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Build a PHP SDK that takes any PSR-18 HTTP client](/howto/php-sdk-on-psr-18-http-clients)

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

[^1]: The PSR-7 meta document spends a section on [why messages are value
objects](https://www.php-fig.org/psr/psr-7/meta/#why-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](https://www.php-fig.org/psr/psr-7/) 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.

[^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](https://github.com/guzzle/guzzle/blob/8.2.0/CHANGELOG.md) 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.

[^3]: The Fetch Standard's redirect algorithm deletes every "CORS non-wildcard request-header
name" when the origin changes, and the
[definition](https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name) 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.