How-to › Authenticate and authorize calls

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.

Audience
API consumer
Level
beginner
Topic
Send API keys and bearer tokens
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Authorization: Bearer header (RFC 6750)Any client that can set a header, against an API that documents bearer tokensA shared cache refuses to reuse the response unless the server marks it public, and every client library has to know the scheme wordThe API documents a custom header name only
CookieA browser session on your own origin, where the browser attaches it for youThe 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 jarAny client that is not a browser
Custom X-API-Key headerAn API whose OpenAPI description declares an apiKey scheme in a headerA header with no standard meaning, so logging and tracing tools do not know it is a secret, and caches treat the response like any otherThe API also accepts Authorization: Bearer
Envoy api_key_auth key_sourcesYour own API behind Envoy, where you choose which of header, query, and cookie it readsA filter whose API is marked work in progress, so you pin the Envoy version and re-read the reference on every upgradeThe keys live in a database rather than in proxy configuration
Kong key-auth key_in_header and key_in_queryYour own API behind Kong Gateway, with consumers that hold keysHeader 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 itNo gateway in the path
Query string parameterA URL that has to work with no control over headers: a link in a browser, an image tag, a webhook addressThe 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 usedAny 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.

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

  1. The header is spelled Referer, and the MDN page on it notes that the name is a misspelling of referrer, one the later Referrer-Policy header does not share. RFC 9110 introduces the field as the Referer header with a bracketed sic, a standard annotating its own vocabulary. ↩︎ Back to text

  2. 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_token is a reserved parameter name, which is counter to the W3C’s advice on URI namespaces. ↩︎ Back to text

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

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.