Authenticating a remote MCP server when the client can't send a header
Claude Desktop and Cursor send an Authorization header. The Claude.ai web Connectors panel only lets you paste a URL. Here is the query-string key fallback and how to make a credential in a URL safe to ship.
by Zechim
We run a public MCP server at zechim.com/mcp. It exposes three read-only tools over a fake e-commerce dataset (the Acme Store from our demo): list_tables, describe_table, and a query tool that runs SELECT-only SQL. Anyone with an API key can point an MCP client at it and ask questions of the data.
The interesting part was not the tools. It was the auth.
The clean path: a Bearer header
Most MCP clients let you configure custom headers. Claude Desktop, Cursor, Cline, and Claude Code all read a config file where you can attach an Authorization header to a remote server:
{
"mcpServers": {
"zechim-demo": {
"url": "https://zechim.com/mcp",
"headers": { "Authorization": "Bearer zk_live_..." }
}
}
}
So the server-side check is the obvious one. Parse the bearer token, hash it, look it up, check the quota:
function parseBearer(header: string | null): string | null {
if (!header) return null;
const m = header.match(/^Bearer\s+(.+)$/i);
return m ? m[1].trim() : null;
}
We hash the presented key with SHA-256 and look up the hash. The plaintext key (zk_live_<43 base64url chars>) is shown to the user exactly once at issuance and never stored. A leaked database gives an attacker hashes, not keys.
This was working for the desktop clients. Then someone tried to connect from the Claude.ai web UI.
The wall: no header field
The Claude.ai web Connectors panel only lets you paste a URL. There is no field for a custom header. The same is true of a few other lightweight MCP UIs: the entire credential has to live in the URL, or you can't connect at all.
You have two options. Refuse those clients, or accept the key in the URL. We accept it.
function parseQueryKey(req: Request): string | null {
try {
const url = new URL(req.url);
// Accept ?key= and the more verbose ?api_key= (MCP UIs template
// one or the other). Whitespace-trim defensively.
const raw =
url.searchParams.get("key") ?? url.searchParams.get("api_key");
if (!raw) return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
The connect URL becomes https://zechim.com/mcp?key=zk_live_... and that is the whole configuration. The header takes precedence when both are present, so a desktop client that sends a header is unaffected:
const header = req.headers.get("authorization");
const raw = parseBearer(header) ?? parseQueryKey(req);
Same key format, same lookup, same quota accounting. Path 2 and path 3 are identical past the parse step.
"But a credential in a URL is dangerous"
It is. That objection is correct, and it is worth being precise about why, because the fix is not "don't do it."
A secret in a query string leaks through channels a header does not:
- It lands in access logs (yours, and any proxy or CDN in front of you).
- It shows up in browser history and in the
Refererheader on outbound links. - It gets copy-pasted into chat and tickets as part of the full URL.
So a key that travels in a URL is a lower-trust credential than one in a header. The mistake is treating that as a yes/no question. The real question is: what is the blast radius if this specific key leaks? Design the credential so the answer is "small," and the query string stops being scary.
Four things make ours acceptable:
1. The key is read-only. The query tool rejects anything that is not a SELECT or WITH at the server. INSERT, UPDATE, DELETE, and DDL never run. A leaked key cannot mutate data, only read a dataset that is already public demo data.
2. The key is revocable and hashed. We store SHA-256 of the plaintext, never the plaintext. Revocation is a single column flip and takes effect on the next request. If a key shows up somewhere it shouldn't, you kill it in one step and issue a new one.
3. The key has a daily quota. Each key carries a daily_quota. The check runs before we bump the counter, so a fresh key with quota 100 allows exactly 100 successful calls, then returns 429 until 00:00 UTC. A leaked key cannot run unbounded; it can burn at most one day of someone else's budget.
4. There is a per-IP burst gate in front of the database. Before we touch Supabase to validate a key at all, an anonymous bucket allows 60 requests per IP per minute. This is not about the legitimate user. It stops a flood of bogus ?key=garbage probes from hammering the auth lookup on every single request.
Put together, the credential in the URL is bounded in scope (read-only), bounded in lifetime (revocable), bounded in volume (daily quota), and shielded from floods (burst gate). A leak is a non-event: revoke, reissue, move on.
Order of checks matters
The wrapper around the MCP handler runs these in a deliberate order, cheapest and most protective first:
async function withAuth(req: Request): Promise<Response> {
// 1. Internal bypass: our own /api/chat connects in-process with a
// server-side secret header. Skips the public burst gate so the
// site's own agent loop doesn't compete for the 60/min budget.
const isInternalBypass =
Boolean(internalKey) &&
req.headers.get("x-internal-key") === internalKey;
// 2. Per-IP burst gate, BEFORE any Supabase call.
if (!isInternalBypass) {
const rl = await checkMcpAnonymousLimit(req);
if (!rl.allowed) return rateLimitJson(rl);
}
// 3. Validate the key (header or query string), check daily quota.
const auth = await authorizeMcpRequest(req);
if ("status" in auth) return jsonRpcError(auth.status, auth.reason);
// 4. Proceed to the actual tools.
return handler(req);
}
The burst gate sits before the auth lookup on purpose. Authentication touches Postgres; rate limiting should not let unauthenticated traffic decide how often Postgres gets touched. The internal bypass exists because our own in-site chat connects to the same MCP server on every turn, and it should not compete with external users for the public budget or burn a visitor's quota.
One more detail worth copying: the quota bump is fire-and-forget. If counting fails, we still serve the request. The worst case is one un-counted call, which is far better than denying a paying user because a write to the usage table hiccuped.
The takeaway
Meet the client where it is. A header is cleaner, but if a real client can only paste a URL, refusing it is a worse outcome than accepting a slightly weaker channel, as long as you do the work to make that channel safe.
The query string is not the problem. An unbounded, long-lived, write-capable key is the problem, and that key is dangerous in a header too. Scope it to read-only, give it a quota, make it revocable, and put a burst gate in front of your database. Then the question of header-versus-query-string becomes a UX detail instead of a security incident.
This is the same MCP server behind our /demo, and the same auth model we reuse across the portfolio: zeobra.com.br, repasse.co, and manufa.com.br each expose data to agents, and each one has to answer "what happens if this credential leaks?" before it ships.
If you are building an MCP server, a public API, or any surface where an agent authenticates on a user's behalf, and you want a second pair of eyes on the credential model before it is in production, let's talk for 30 minutes.