The API you are integrating takes its key three ways, Authorization: Bearer, an X-API-Key
header, or ?api_key= on the URL, and its quick start shows the last one because it works in a
browser tab. Your first integration copied the quick start. Three weeks later the key turns up, in
full, in a log file somebody attached to a support ticket.
What you get
You will end up with a rule for where your key travels, and a local proxy and API that show which records keep a key for each placement. This is for you if the API you call documents more than one placement.
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) | 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 | 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 | 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 | 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 | 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 | 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.
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.
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.
node demo.mjs
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 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 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
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
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 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 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 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
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.
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
node --test placement.test.mjs
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 does unless the
replacement ends in a question mark.
node pitfall.mjs
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
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.
Footnotes
-
The header is spelled
Referer, and the MDN page on it notes that the name is a misspelling of referrer, one the laterReferrer-Policyheader does not share. RFC 9110 introduces the field as theRefererheader with a bracketedsic, a standard annotating its own vocabulary. ↩︎ Back to text -
Section 2.3 of RFC 6750 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_tokenis a reserved parameter name, which is counter to the W3C’s advice on URI namespaces. ↩︎ Back to text -
The proto reference 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. ↩︎ Back to text