# How to choose between header and query placement for an API key

> Pick where an API key travels, header, query string, or cookie, by sending each form through a logging proxy and reading back which records keep the key.

Source: https://voxgig.com/howto/header-or-query-api-key-placement

- Audience: api-consumer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-10-20
- Published: 2026-10-17

## Short answer

Send the key in the `Authorization: Bearer` header when the API accepts it, and in a custom header such as `X-API-Key` when that is what it documents. Keep it out of the query string: a key in the URL is written to the access log, the proxy log, the browser history, and every cache key. Use a cookie only for a browser session on your own origin. Confirm the choice by sending each form through a logging proxy.

---
## You will need

Node 22 or later, `curl` for the one-liners, and a key for an API that documents more than one
placement. Verified 2026-10-20 against Node 22.22.0. The sample is an API on the loopback interface that accepts all four placements, behind a forward
proxy that keeps request lines. A client sends the same key each way, all of it on the loopback interface. The two gateway
rows are read from their documentation rather than run.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Authorization: Bearer header (RFC 6750)](https://www.rfc-editor.org/rfc/rfc6750#section-2.1) | Any client that can set a header, against an API that documents bearer tokens | A shared cache refuses to reuse the response unless the server marks it public, and every client library has to know the scheme word | The API documents a custom header name only |
| [Cookie](https://www.rfc-editor.org/rfc/rfc6265) | A browser session on your own origin, where the browser attaches it for you | The browser sends it on every request to that host, so cross-site request forgery and `SameSite` become your problem, and a programmatic client needs a cookie jar | Any client that is not a browser |
| [Custom X-API-Key header](https://spec.openapis.org/oas/v3.1.1.html#security-scheme-object) | An API whose OpenAPI description declares an `apiKey` scheme in a header | A header with no standard meaning, so logging and tracing tools do not know it is a secret, and caches treat the response like any other | The API also accepts `Authorization: Bearer` |
| [Envoy api_key_auth `key_sources`](https://www.envoyproxy.io/docs/envoy/v1.40.0/configuration/http/http_filters/api_key_auth_filter) | Your own API behind Envoy, where you choose which of header, query, and cookie it reads | A filter whose API is marked work in progress, so you pin the Envoy version and re-read the reference on every upgrade | The keys live in a database rather than in proxy configuration |
| [Kong key-auth `key_in_header` and `key_in_query`](https://developer.konghq.com/plugins/key-auth/reference/) | Your own API behind Kong Gateway, with consumers that hold keys | Header and query are both on by default, so the gateway accepts the URL form until `key_in_query` is turned off, and the key reaches your service unless `hide_credentials` strips it | No gateway in the path |
| [Query string parameter](https://www.rfc-editor.org/rfc/rfc6750#section-2.3) | A URL that has to work with no control over headers: a link in a browser, an image tag, a webhook address | The key is written to access logs, proxy logs, browser history, and cache keys, and the RFC that defines the form says it SHOULD NOT be used | Any client that can set a header |

The two headers and the cookie keep the key out of the URL. That is the whole difference, because
the URL is the one part of a request that everything in the path writes down. Between the
two headers, `Authorization` carries a meaning the rest of HTTP understands: a shared cache will
not reuse the response, and a logging tool knows to redact it. The gateway rows show the other
side, where an operator decides which placements the API reads at all.

## Send the same key four ways through a logging proxy

The client knows the four placements as request options. Everything else about the request is the
same, which is what makes the records comparable.

```js title="client.mjs"
export const PLACEMENTS = {
  'Authorization: Bearer': (key) => ({ headers: { authorization: `Bearer ${key}` } }),
  'X-API-Key header': (key) => ({ headers: { 'x-api-key': key } }),
  'query parameter': (key) => ({ query: `?api_key=${encodeURIComponent(key)}` }),
  cookie: (key) => ({ headers: { cookie: `api_key=${key}` } }),
}
```

The query form percent-encodes the key, so a base64 key survives the trip and an `&` in a key
cannot start a second parameter.

The proxy is thirty lines of `node:http` that forwards whatever it receives and keeps the request
line, which is the one thing every proxy keeps. The API logs each request in the combined format
nginx writes by default, in which the whole request line, query string included, is the
fifth field.

```js title="api.mjs"
export function accessLogLine(req, status, bytes, when) {
  const pad = (n) => String(n).padStart(2, '0')
  const stamp = `${pad(when.getUTCDate())}/${MONTHS[when.getUTCMonth()]}/${when.getUTCFullYear()}:${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())} +0000`
  const referer = req.headers.referer ?? '-'
  const agent = req.headers['user-agent'] ?? '-'
  return `127.0.0.1 - - [${stamp}] "${req.method} ${req.url} HTTP/1.1" ${status} ${bytes} "${referer}" "${agent}"`
}
```

Send the key each way and read back where it was written.

```bash
node demo.mjs
```

```text output
Authorization: Bearer
  response:        200 {"ok":true,"via":"authorization header"}
  api access log:  127.0.0.1 - - [17/Oct/2026:10:00:00 +0000] "GET /v1/things HTTP/1.1" 200 40 "-" "-"
  proxy log:       GET http://127.0.0.1:43781/v1/things
  key stored in:   neither record
X-API-Key header
  response:        200 {"ok":true,"via":"x-api-key header"}
  api access log:  127.0.0.1 - - [17/Oct/2026:10:00:00 +0000] "GET /v1/things HTTP/1.1" 200 36 "-" "-"
  proxy log:       GET http://127.0.0.1:43781/v1/things
  key stored in:   neither record
query parameter
  response:        200 {"ok":true,"via":"query parameter"}
  api access log:  127.0.0.1 - - [17/Oct/2026:10:00:00 +0000] "GET /v1/things?api_key=ex_live_4f9d2c7a1b8e6035c2d9e1f0a7b3c4d5 HTTP/1.1" 200 35 "-" "-"
  proxy log:       GET http://127.0.0.1:43781/v1/things?api_key=ex_live_4f9d2c7a1b8e6035c2d9e1f0a7b3c4d5
  key stored in:   api access log, proxy log
cookie
  response:        200 {"ok":true,"via":"cookie"}
  api access log:  127.0.0.1 - - [17/Oct/2026:10:00:00 +0000] "GET /v1/things HTTP/1.1" 200 26 "-" "-"
  proxy log:       GET http://127.0.0.1:43781/v1/things
  key stored in:   neither record
```

All four requests were accepted. The fourth wrote the key, in full, into the API's own access log
and into the proxy's, and both records are kept by people who never asked to hold a credential. [mitmproxy](https://docs.mitmproxy.org/stable/) on your laptop shows the same line in its flow
list.

## Read the records a URL leaves behind

A URL is copied more often than any other part of a request. nginx's
[combined log format](https://nginx.org/en/docs/http/ngx_http_log_module.html) stores `$request`,
the request line, so the query string is in every access log by default. A CDN keys its cache on
the URL: Cloudflare's [default cache key](https://developers.cloudflare.com/cache/how-to/cache-keys/)
is the scheme, the host, and the URI with its query string. A browser keeps the URL in its history
and sends it as the `Referer` on same-origin requests. The default
[referrer policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy)
sends only the origin to other sites, so the cross-site leak needs a page that loosened the policy,
while the same-origin one is routine.[^1]

[RFC 6750](https://www.rfc-editor.org/rfc/rfc6750#section-2.3) defines the query form for bearer
tokens. The same section says it SHOULD NOT be used unless a header or a body is impossible, and
names the high likelihood that the URL will be logged.[^2] Its security section puts it more
plainly: do not pass bearer tokens in page URLs.

The two headers differ in what the rest of HTTP does with them.
[RFC 9111](https://www.rfc-editor.org/rfc/rfc9111#section-3.5) forbids a shared cache from reusing
a response to a request that carried `Authorization` unless the response is marked `public`,
`must-revalidate`, or `s-maxage`. A request that carried `X-API-Key` gets no such rule. If the server forgot `Cache-Control:
private`, a response for one key can be served to the next caller. The same asymmetry runs through logging and tracing tools, which redact `Authorization`
by name and have no way to know a custom header is a secret.

## Read what the gateway on the other side accepts

An API that accepts more than one placement is usually a gateway doing so. Kong's
[key-auth plugin](https://developer.konghq.com/plugins/key-auth/reference/) reads the header and
the query string by default, under the parameter names in `key_names`, and reads the body only
when `key_in_body` is on. `hide_credentials` strips the key before the request reaches the
service. Envoy's [api_key_auth filter](https://www.envoyproxy.io/docs/envoy/v1.40.0/configuration/http/http_filters/api_key_auth_filter)
takes a `key_sources` list of header, query, and cookie names, strips a `Bearer` prefix from a
header value, and prefers the header when a request carries more than one. Its reference carries a warning that the API is work in progress, not covered by the threat model,
and subject to breaking changes.[^3] A configuration built on it names the Envoy version it was
tested against.

The sample API resolves placements in the same order as Envoy, header first.

```js title="api.mjs"
export function keyFrom(req, url) {
  const auth = req.headers.authorization ?? ''
  if (auth.startsWith('Bearer ')) return { via: 'authorization header', value: auth.slice(7) }
  if (req.headers['x-api-key']) return { via: 'x-api-key header', value: req.headers['x-api-key'] }
  if (url.searchParams.has('api_key')) return { via: 'query parameter', value: url.searchParams.get('api_key') }
  const cookie = /(?:^|;\s*)api_key=([^;]+)/.exec(req.headers.cookie ?? '')
  if (cookie) return { via: 'cookie', value: cookie[1] }
  return { via: 'none', value: null }
}
```

As a consumer you choose the placement, not the list. When the API documents both a header and
the query string, send the header: `curl -H 'Authorization: Bearer KEY'` costs one flag more than
`?api_key=KEY` and writes nothing into the URL.

## Check it worked

```bash
node --test placement.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 174.585924
```

The second test sends the key as a bearer header, as `X-API-Key`, and as a cookie, and asserts
that neither the access log nor the proxy log contains it. The third sends it in the query string
and asserts that both do, with the literal `api_key=` in front of it.

## When it goes wrong

The key is in the log twice, and in a `Location` header. The API redirects `/v1/report` to
`/v1/reports` and keeps the query string, as nginx's
[`rewrite`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#rewrite) does unless the
replacement ends in a question mark.

```bash
node pitfall.mjs
```

```text output
Authorization: Bearer
  response:  301 location: /v1/reports
  api log:   127.0.0.1 - - [17/Oct/2026:10:00:00 +0000] "GET /v1/report HTTP/1.1" 301 0 "-" "-"
query parameter
  response:  301 location: /v1/reports?api_key=ex_live_4f9d2c7a1b8e6035c2d9e1f0a7b3c4d5
  api log:   127.0.0.1 - - [17/Oct/2026:10:00:00 +0000] "GET /v1/report?api_key=ex_live_4f9d2c7a1b8e6035c2d9e1f0a7b3c4d5 HTTP/1.1" 301 0 "-" "-"
```

A key in the URL travels with every URL the server derives from it: the redirect target, the
canonical link, the next page of a paginated list. The header form redirects to a clean URL and the
client re-sends the header. A plain-HTTP address that redirects to HTTPS is the worst case, because
the key crossed the network in the clear before the redirect arrived.

The key appears in your own logs after you moved it to a header. The logger redacts
`Authorization` by name and prints every other header, and `X-API-Key` is every other header. Add
the header name to the redaction list, or use the bearer form when the API accepts it.

The proxy log still shows the key for one call. The API accepts both placements, so the one call
site that kept the quick start's URL keeps working and keeps logging. The demo's `key stored in`
line is the check to repeat on your own traffic: send it through mitmproxy and search the flow list
for the key.

## When not to do this

Do not put the key in the query string to save a flag in `curl`. The one-liner is shorter by a
header and longer by every log it lands in.

Do not reuse your main key when a webhook address or an image tag leaves you no header. That is
the one case the RFC allows. Give it a key issued for that use alone, with the narrowest scope the
provider offers, so the copy in the logs unlocks as little as possible.

Do not send a cookie from a programmatic client. A cookie is the browser's placement: the browser
attaches it, scopes it to a host and a path, and refuses it to scripts when it is marked
`HttpOnly`. A server-side client gains none of that and inherits a cookie jar and the cross-site
request forgery rules instead.

Do not trust the custom header to be treated as a secret by anything but your own code. Caches,
loggers, and tracers know `Authorization`. They know `X-API-Key` only when you tell them.

Do not accept both placements on an API you run. The consumer's choice is only as good as the
server's list, and `key_in_query` is a switch.

## Related how-tos

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

- [Call a keyed API from a browser without shipping the key](/howto/keep-an-api-key-out-of-browser-code)

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

## Last verified

Verified 2026-10-20 against Node 22.22.0. Every output block is what the command preceding it
printed, against the API and the forward proxy in this directory on the loopback interface, with
a fixed clock for the log stamps. Kong key-auth and Envoy's api_key_auth filter were not run; their
behavior is read from the pages the table links, the Envoy page at its v1.40.0 documentation.
mitmproxy was not run either, and is named as the tool to repeat the experiment with.

[^1]: The header is spelled `Referer`, and the
[MDN page on it](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referer)
notes that the name is a misspelling of referrer, one the later `Referrer-Policy` header does not
share. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.3) introduces the field as
the `Referer` header with a bracketed `sic`, a standard annotating its own vocabulary.

[^2]: Section 2.3 of [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750#section-2.3) explains why
it defines a method it then advises against: the query form is included to document current use.
It adds a second objection, that `access_token` is a reserved parameter name, which is counter to
the W3C's advice on URI namespaces.

[^3]: The [proto reference](https://www.envoyproxy.io/docs/envoy/v1.40.0/api-v3/extensions/filters/http/api_key_auth/v3/api_key_auth.proto)
for the filter carries a boxed warning. The API feature is work-in-progress, not considered
stable, not covered by the threat model, not supported by the security team, and subject to
breaking changes. The filter decides which requests reach the service, so the threat model leaves
out the code that does the authenticating. The warning's last line asks the reader not to use the
feature without understanding each of those points.