A reusable bearer access token grants its holder defined access; sender-constrained tokens additionally require proof tied to the sender. Putting it in a URL exposes that capability to systems that routinely copy URLs: access logs, monitoring records, browser navigation history, bookmarks, and shared links. The exact exposure depends on the request and policy, but a secret-looking query parameter is not automatically protected by its name. Treat transport placement as part of the credential’s disclosure boundary.
For OAuth bearer-token requests, use the Authorization header. RFC 9700 now prohibits clients from passing access tokens in URI query parameters using RFC 6750’s query method. HTTPS remains essential: moving a value from the query to a header does not encrypt it, and TLS protects request data in transit to its termination point. After termination, applications and intermediaries can still inspect it. A header keeps the value out of the request URL, rather than granting immunity from logging or diagnostic capture.
Distinguish reduced exposure from secrecy
Some access logs record only method, request target, and status. In that format, a header token does not appear in the target, while a query token does. Other systems capture request headers, so Authorization needs explicit handling in log, trace, and error-reporting configuration. Verify what the actual pipeline stores instead of assuming that headers bypass every logging layer. Avoid exposing real credentials while performing that inspection.
Referrer-Policy can reduce onward source-URL disclosure, but does not remove a query already sent to the destination, erase browser records, or rewrite copied links. Likewise, Cache-Control: no-store is not a general instruction to purge access logs or navigation history. Moving a token to a fragment changes what is sent in an HTTP request, but still leaves a value accessible to page scripts and browser URL state; it is not a universal credential-storage solution.
When it breaks
A team may replace query authentication with a header and leave old token-bearing URLs in reports or shared bookmarks. The transport change does not undo that exposure. Investigate the affected copies and invalidate exposed reusable credentials where the system supports it. Another failure is a supposedly temporary link whose capability remains useful to anyone who obtains it during its validity period. Short lifetime reduces that window without making disclosure harmless.
Signed URLs can be an intentional design for narrowly scoped resource access when the chosen client flow requires them. Their allowed resource, operation, expiry, and handling need explicit review. Expiration is not the same as single use: preventing reuse before expiry requires a mechanism that actually enforces consumption. A webhook or browser navigation does not automatically justify putting an unrestricted reusable API credential into a query; choose an authentication flow suitable for the real client.
Check it quickly
Save this as token-placement.py, run python3 token-placement.py --serve, and open its printed page. The original article reports Python 3.14.4 and Chrome 152; this source review did not reproduce that run. Explicit Python checks also run under optimization. Every value is the literal synthetic marker DEMO-NOT-A-SECRET; never substitute a real credential. The loopback server is an observation fixture, not an authentication service. It records request targets and whether a demonstration Authorization header was present, without recording that header’s value. Python disables proxy discovery and rejects redirects; control browser proxy policy separately. Serve mode lasts 120 seconds, with --serve-seconds limited to 1–300. Control-C also requests cleanup. Only the separate cleanup result confirms that the server thread stopped and listener closed; bounded joins fail if shutdown remains unverified.
import argparse
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
parser = argparse.ArgumentParser()
parser.add_argument("--serve", action="store_true")
parser.add_argument("--serve-seconds", type=int, default=120)
args = parser.parse_args()
if not 1 <= args.serve_seconds <= 300:
parser.error("--serve-seconds must be between 1 and 300")
def require(condition, message):
if not condition:
raise AssertionError(message)
# Synthetic demonstration value only; this server is not an authentication service.
demo = "DEMO-NOT-A-SECRET"
events = []
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise RuntimeError("Unexpected redirect in local fixture")
opener = build_opener(ProxyHandler({}), NoRedirect())
class LocalServer(HTTPServer):
def get_request(self):
connection, address = super().get_request()
connection.settimeout(2)
return connection, address
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
path = urlsplit(self.path).path
if path == "/events":
body = json.dumps(events).encode()
kind = "application/json"
elif path == "/echo":
entry = {"request_target": self.path,
"authorization_present": self.headers.get("Authorization") == "Bearer " + demo,
"referer": self.headers.get("Referer")}
events.append(entry)
body = json.dumps(entry).encode()
kind = "application/json"
elif path == "/":
events.append({"request_target": self.path, "document": True})
body = ("""<!doctype html><meta charset="utf-8"><title>Token placement lab</title>
<h1>A clean referrer does not erase the request URL</h1><pre id="result">Running...</pre><script>
const demo=""" + json.dumps(demo) + """;
function require(condition, message) {
if (!condition) throw new Error(message);
}
function verifyObservation(result, origin) {
const queryTarget='/echo?access_token='+demo;
require(new URL(result.page).origin===origin &&
new URL(result.page).pathname==='/' &&
new URL(result.page).search==='?access_token='+demo, 'page URL mismatch');
require(result.query.request_target===queryTarget &&
result.query.authorization_present===false, 'query placement mismatch');
require(result.header.request_target==='/echo' &&
result.header.authorization_present===true, 'header placement mismatch');
require(result.query.referer===null && result.header.referer===null,
'Referer was not suppressed');
require(result.resourceUrls.includes(origin+queryTarget) &&
result.resourceUrls.includes(origin+'/echo'), 'resource URLs missing');
require(result.events.some(e=>e.document===true &&
e.request_target==='/?access_token='+demo), 'document event missing');
for (const expected of [result.query,result.header]) {
require(result.events.some(e=>e.request_target===expected.request_target &&
e.authorization_present===expected.authorization_present &&
e.referer===null), 'matching server event missing');
}
}
async function readJson(path, options={}) {
const response=await fetch(path,{...options,redirect:'error',
signal:AbortSignal.timeout(5000)});
require(response.ok, 'HTTP status '+response.status);
return response.json();
}
async function run() {
require(location.protocol==='http:' && location.hostname==='127.0.0.1',
'Use the printed loopback URL');
performance.clearResourceTimings();
const query=await readJson('/echo?access_token='+demo);
const header=await readJson('/echo',{headers:{Authorization:'Bearer '+demo}});
const deadline=performance.now()+5000;
let resourceUrls=[];
do {
resourceUrls=performance.getEntriesByType('resource').map(e=>e.name)
.filter(name=>new URL(name).pathname==='/echo');
if (resourceUrls.length>=2) break;
await new Promise(resolve=>setTimeout(resolve,25));
} while (performance.now()<deadline);
const result={page:location.href,query,header,resourceUrls,
events:await readJson('/events')};
verifyObservation(result,location.origin);
window.labResult={status:'PASS',...result};
document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2);
}
run().catch(error=>{
window.labError=String(error);
window.labResult={status:'FAIL',error:window.labError};
document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2);
});
</script>""").encode()
kind = "text/html; charset=utf-8"
else:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def close_server(server, thread):
stopper = None
try:
if thread.is_alive():
stopper = Thread(target=server.shutdown, daemon=True)
stopper.start()
stopper.join(timeout=3)
if thread.ident is not None:
thread.join(timeout=3)
require((stopper is None or not stopper.is_alive()) and not thread.is_alive(),
"Server shutdown not verified")
finally:
server.server_close()
require(server.socket.fileno() == -1, "Listener close not verified")
print(json.dumps({"cleanup": "server thread stopped and listener closed"}), flush=True)
server = LocalServer(("127.0.0.1", 0), Handler)
thread = Thread(target=lambda: server.serve_forever(poll_interval=0.1), daemon=True)
origin = f"http://127.0.0.1:{server.server_port}"
try:
thread.start()
with opener.open(origin + "/echo?access_token=" + demo, timeout=5) as response:
query = json.load(response)
with opener.open(Request(origin + "/echo", headers={"Authorization": "Bearer " + demo}), timeout=5) as response:
header = json.load(response)
require(query["request_target"] == "/echo?access_token=" + demo and
query["authorization_present"] is False, "query placement mismatch")
require(header["request_target"] == "/echo" and
header["authorization_present"] is True, "header placement mismatch")
events.clear()
print(json.dumps({"app_url": origin + "/?access_token=" + demo,
"wire_placement_checks": "PASS", "browser_checks": "NOT RUN BY PYTHON",
"serve_seconds": args.serve_seconds if args.serve else 0,
"real_credentials_used": False}), flush=True)
if args.serve:
Event().wait(args.serve_seconds)
except KeyboardInterrupt:
pass
finally:
close_server(server, thread)
The page requires the printed loopback address, then makes query and header requests with five-second abort signals. Its browser assertions require null received Referer values, exact request targets, both resource-timing URLs, the current page URL, and matching server events. Resource entries get a five-second polling deadline; browser suspension can delay timers. Only then does the page show PASS; missing or mismatched values show FAIL. The current page URL is narrower evidence than a persistent-history audit.
The article reports comparison with browser state and the server event list; no hash-bound browser trace was recovered here. Python checks only request-target placement and the demo-header match. The added browser assertions have not run here; Python output cannot certify them. Runtime and end-to-end technical acceptance remain blocked. The experiment does not test production authentication, TLS termination, log redaction, or signed-link consumption. Apply the observation to the actual client and logging system: keep reusable credentials out of URL surfaces where possible, deliberately protect the remaining credential-bearing fields, and verify the storage and sharing paths that continue after the request finishes.
Sources
RFC 6750 OAuth 2.0 Bearer Token Usage, Authorization header use, query-method limitations, and TLS requirements, checked 2026-09-13.
OWASP information exposure through URL query strings, URL persistence and disclosure through logs and browser records, checked 2026-09-13.
MDN Referrer-Policy, what suppressing referrers does and does not control, checked 2026-09-13.
RFC 9700 OAuth 2.0 Security BCP, query-token prohibition and sender-constrained tokens, checked 2026-10-18.
W3C Resource Timing, resource entries retain the requested URL independently of Referer, checked 2026-10-18.
RFC 9111 HTTP Caching, cache directives and separately implemented application history, checked 2026-10-18.
Python urllib.request, default proxy discovery and explicit opener scope, checked 2026-10-18.
Python socketserver, shutdown must be called from another thread, checked 2026-10-19.
WHATWG Fetch, abort signals and redirect error mode, checked 2026-10-19.
WHATWG DOM, AbortSignal timeout and active-time limits, checked 2026-10-19.