How Authentication Works in the You.com Web Search API

TLDR: The You.com REST APIs authenticate with one header, X-API-Key, set to your raw API key with no Bearer prefix and no token exchange. Web Search and Contents run on ydc-index.io; Answer, Research, and Finance Research run on api.you.com. The hosted MCP server is the exception: it takes Authorization: Bearer or OAuth 2.1. Failures return documented 401, 402, and 403 bodies, listed below.
This page covers authentication only: the exact header contract, the error responses you will see when it breaks, and how to keep the key out of browsers, logs, and redirects. For complete request walkthroughs, use the cURL guide, the Python SDK guide, or the TypeScript guide. For what the API returns and why, see what a web search API is.
What is the exact header contract?
The authentication docs define one rule: every request carries the API key in the X-API-Key header. The value is the key string itself, with no prefix, no encoding, and nothing to refresh. Header names are case-insensitive in HTTP (RFC 9110, section 5.1), so x-api-key is the same header, but the value must be the exact key. The docs, the SDKs, and every integration example read that key from one environment variable, YDC_API_KEY. The key belongs in the header on GET requests too: the reference defines no query-string alternative, and URLs routinely end up in proxy and server logs.
Keys are also scoped per product. A key created with Web Search access only returns 403 on /v1/contents, so confirm the scope before reusing a search key for the Contents API. The endpoint table lists which host serves each API:
| API | Endpoint | Credential |
|---|---|---|
| Web Search | POST https://ydc-index.io/v1/search (GET still works but receives no new features) | X-API-Key |
| Contents | POST https://ydc-index.io/v1/contents | X-API-Key |
| Answer | POST https://api.you.com/v1/answer | X-API-Key |
| Research | POST https://api.you.com/v1/research, plus its background task endpoints | X-API-Key |
| Finance Research | POST https://api.you.com/v1/finance_research | X-API-Key |
| Account Balance | GET https://api.you.com/v1/billing/account_balance | X-API-Key |
| MCP server | POST https://api.you.com/mcp, /mcp/research, /mcp/finance | Authorization: Bearer, OAuth 2.1, or a keyless profile |
The request below is the documented contract plus three hardening steps. It reads the key without writing it to shell history, stops when the variable is empty, and passes the header on standard input so the key never appears in the process list. The curl manual itself says credentials should come from a file or similar and never sit in clear text on a command line, where other users on the same machine can see them.
# Read the key without echoing it or writing it to shell history
read -rs YDC_API_KEY && export YDC_API_KEY
# Stop if the variable is empty: curl silently drops a header with no value
: "${YDC_API_KEY:?YDC_API_KEY is empty}"
# printf is a shell builtin, so the key never appears in the process list.
# curl reads the header from stdin (-H @-). No -L: curl re-sends custom
# headers such as X-API-Key when it follows a redirect to another host.
printf 'X-API-Key: %s\n' "$YDC_API_KEY" |
curl -sS -X POST https://ydc-index.io/v1/search \
-H @- \
-H "Content-Type: application/json" \
-d '{"query": "http header field names case insensitive", "count": 3}' \
-w '\nHTTP %{http_code}\n'
Two details in that script are easy to get wrong. First, curl drops a header whose value is empty, so an unset variable does not send an empty X-API-Key. It sends no header at all, and the server answers with the missing-key 401. Second, the curl manual warns that headers set with -H are re-sent after redirects, including to other hosts, while Authorization and Cookie are stripped on cross-origin redirects. X-API-Key is a custom header, so -L can hand your key to whatever host a redirect names. Leave -L off authenticated calls.
What do the authentication error responses look like?
The error reference documents a body for each authentication failure. Match on the status code first, then read the body for the specific cause.
| Status | Documented body | What it means | Retry? |
|---|---|---|---|
| 401 | {"detail": "API key is required"} | No key reached the server | No. Fix the environment or the header name. |
| 401 | {"detail": "Invalid or expired API key"} | The key is wrong, revoked, or deleted | No. Replace the key. |
| 402 | {"detail": "Insufficient credits"} | A keyed request found the balance empty | No. Add credits. |
| 402 | PAYMENT-REQUIRED header with payment terms | A keyless request reached an endpoint that sells access per call | No. Send the key. |
| 403 | {"detail": "Missing required scopes"} | The key is valid but not scoped for this API | No. Use a key with that scope. |
| 403 | {"message": "Missing Authentication Token"} | The path is not served on this host | No. Fix the host. |
| 429 | Retry-After header when present | Rate limit, not an authentication failure | Yes, with backoff. |
| 500 | {"detail": "Internal authentication error"} or {"detail": "Internal authorization error"} | The authentication middleware failed on the server side | With a cap. Contact support if it persists. |
Two rows in that table catch people. The 402 body is documented in two shapes: the error reference shows {"detail": "Insufficient credits"}, while the Search API reference describes error, message, and upgrade_url fields for the same status, and the Python SDK's typed 402 error, raised by answer, exposes message and upgrade_url. Code that branches on the status code survives either shape. Code that string-matches one body does not.
The other surprise is a keyless request that returns 402 instead of 401. According to the machine payments docs, GET /v1/search accepts per-call payment through x402 and MPP, so a GET with no key receives a payment challenge, while POST /v1/search returns 401. If an empty variable turns your GET call into what looks like a billing problem, check the key first. The x402 walkthrough covers the intentional version of that flow.
This standard-library Python client applies those rules. It strips the key once at load time, refuses to send a keyless request, refuses redirects because urllib would otherwise copy X-API-Key to the new host, and turns each documented failure into a label your logs and alerts can use.
import json
import os
import urllib.error
import urllib.request
SEARCH_URL = "https://ydc-index.io/v1/search"
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # urllib would otherwise copy X-API-Key to the new host
class YouAuthError(Exception):
def __init__(self, status, label, action):
super().__init__("HTTP %d %s: %s" % (status, label, action))
self.status, self.label, self.action = status, label, action
def load_key(name="YDC_API_KEY"):
key = (os.environ.get(name) or "").strip() # strip stray newlines from files
if not key:
raise RuntimeError(name + " is unset or blank; refusing to send a keyless request")
return key
def classify(status, body, headers):
"""Map a documented failure to (label, action). Branch on status first."""
try:
data = json.loads(body or b"{}")
except ValueError:
data = {}
if not isinstance(data, dict):
data = {}
detail = str(data.get("detail") or data.get("message") or data.get("error") or "")
if 300 <= status < 400:
return "redirect_refused", "check the base URL; the key was not re-sent"
if status == 401 and "required" in detail:
return "missing_key", "no X-API-Key arrived; check the variable and header name"
if status == 401:
return "rejected_key", "key is wrong, revoked, or deleted; check the Platform"
if status == 402 and headers.get("PAYMENT-REQUIRED"):
return "payment_challenge", "the request went out without a key"
if status == 402:
return "out_of_credits", "add credits or check Auto Top-Up; do not retry"
if status == 403 and "Missing Authentication Token" in detail:
return "wrong_host", "this path is not served on this host"
if status == 403:
return "missing_scope", "create a key scoped for this API"
if status == 429:
return "rate_limited", "wait for Retry-After, then back off"
if status == 500 and detail.startswith("Internal auth"):
return "auth_service_error", "retry with backoff; contact support if it persists"
return "http_error", detail or "unexpected status"
def search(query, count=3):
request = urllib.request.Request(
SEARCH_URL,
method="POST",
data=json.dumps({"query": query, "count": count}).encode("utf-8"),
headers={"X-API-Key": load_key(), "Content-Type": "application/json"},
)
opener = urllib.request.build_opener(NoRedirect())
try:
with opener.open(request, timeout=15) as response:
return json.loads(response.read())
except urllib.error.HTTPError as err:
label, action = classify(err.code, err.read(), err.headers)
raise YouAuthError(err.code, label, action) from None
if __name__ == "__main__":
data = search("rfc 9110 header field names")
for hit in (data.get("results") or {}).get("web") or []:
print(hit.get("title"), hit.get("url"))
Retry only rate_limited and, with a cap, auth_service_error. Every other label means the same request will fail the same way.
What usually causes a 401 or 403?
The response body usually narrows a failure to one of these causes.
- The variable never reached the process. Containers, cron jobs, systemd units, and CI runners do not inherit your interactive shell. The result is the missing-key 401. The startup checks in the examples catch this before the first request leaves.
- Invisible characters. A key pasted into a file often carries a trailing newline or space, and clients disagree about what to do with it. In local tests, Python's
http.clientraisedValueError: Invalid header valuebefore sending, Node'sfetchtrimmed the whitespace silently, and curl, per its manual, passes header values through verbatim. Strip the key once when you load it. - The wrong auth style. The REST reference documents
X-API-Key. The MCP server documentsAuthorization: Bearer. Copying a header from an MCP config into a REST client, or the reverse, is a common cross-wiring. - The wrong host or scope. An Answer, Research, or Finance Research path sent to
ydc-index.ioreturns 403 withMissing Authentication Token, even with a valid key. A Web Search key calling/v1/contentsreturns 403 withMissing required scopes. - A deleted key. Deleting a key revokes it immediately. The team management docs add a sharper edge: removing a member from an organization permanently deletes every key that member created, so production traffic on a departed engineer's key starts failing with 401 the moment they are removed.
Why must the key stay on the server?
A key shipped in a browser bundle or a mobile binary belongs to anyone who installs your app. The OWASP Mobile Top 10 treats hardcoded credentials in app code or configuration files as a clear indicator of M1, Improper Credential Usage, and notes that attackers find them with publicly available or custom-built tools. Browser code is no better: a header your frontend sends is visible in the developer tools of every user.
The cost of a leak is easy to estimate. As of September 2026, Web Search is billed at $5.00 per 1,000 calls, and the default self-serve rate limit on /v1/search is 10 requests per second. A leaked search key can therefore spend up to $180 an hour on base calls alone (10 calls a second for 3,600 seconds at $0.005 each), before any extraction charges. The billing docs say spending caps are not yet available, and Auto Top-Up, when enabled, buys more credits whenever the balance crosses its threshold. The balance does not stop a leak. Revoking the key does.
The fix is a thin server-side proxy. The browser calls your endpoint under your own user session, and the proxy adds the key and forwards only the fields you allow. This dependency-free Node version was tested against a local mock of the documented responses:
// search-proxy.mjs: Node 18+, no dependencies. Browsers call POST /api/search;
// only this server process ever holds YDC_API_KEY.
import http from "node:http";
const KEY = (process.env.YDC_API_KEY ?? "").trim();
if (!KEY) throw new Error("YDC_API_KEY is unset or blank");
const UPSTREAM = "https://ydc-index.io/v1/search";
function send(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/api/search") {
return send(res, 404, { error: "not found" });
}
// Authenticate your own user here (session cookie or JWT) and rate limit per user.
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 2048) return send(res, 413, { error: "body too large" });
}
let input;
try {
input = JSON.parse(raw);
} catch {
return send(res, 400, { error: "invalid JSON" });
}
const query = typeof input?.query === "string" ? input.query.trim() : "";
if (!query || query.length > 400) {
return send(res, 400, { error: "query must be 1 to 400 characters" });
}
const count = Math.min(Math.max(Number.parseInt(input?.count, 10) || 5, 1), 10);
let upstream;
try {
upstream = await fetch(UPSTREAM, {
method: "POST",
// Build headers from scratch: never forward the browser's headers.
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ query, count }), // allowlisted fields only
redirect: "error", // fetch keeps X-API-Key on cross-origin redirects
signal: AbortSignal.timeout(15_000),
});
} catch (err) {
console.error("search upstream failed:", err.name);
return send(res, 504, { error: "search unavailable" });
}
if (!upstream.ok) {
// Alert on the status; never log request headers or echo account state.
console.error("search upstream status", upstream.status);
return send(res, upstream.status === 429 ? 429 : 502, { error: "search unavailable" });
}
const data = await upstream.json();
const web = (data.results?.web ?? []).map(({ title, url, snippets }) => ({ title, url, snippets }));
return send(res, 200, { web });
}).listen(8787);
Each guard has a job. Building headers from scratch means a browser can never inject its own X-API-Key or cookies upstream. The allowlist and the count cap stop a caller from requesting full-page extraction or large result sets on your bill. redirect: "error" matters because Node's fetch, like curl, strips Authorization on a cross-origin redirect but keeps custom headers. Mapping upstream 401, 402, and 403 responses to a generic 502 keeps your account state private, while your alerts read the logged status instead. The same shape works as a Next.js route handler or a serverless function, as long as the key lives only in server-side environment variables.
How should you name, store, and rotate keys?
Naming. Use YDC_API_KEY everywhere. The Python SDK (youdotcom 3.5.0, Python 3.10 or later) reads YDC_API_KEY when api_key_auth is omitted or None, then falls back to the legacy YOU_API_KEY_AUTH. It raises ValueError on an empty or blank string rather than silently picking up another identity, as the SDK README explains. The TypeScript SDK takes the key as apiKeyAuth, and the docs pass process.env.YDC_API_KEY explicitly. Do the same, because that SDK's README names the legacy variable in its security table.
One key per environment. Create keys on the API Keys page of the Platform. As of September 2026, new accounts start with $100 in free API credits, and no credit card is required. The API key docs recommend distinct keys for development, staging, and production, named for the environment. The Platform shows the full key once, at creation, and afterward lists only a masked value, the creation date, and the last-used date. Separate keys mean a leaked laptop key can be revoked without touching production, and the last-used dates show which environment still depends on which key.
Storage. Keep keys in a secrets manager, or have your orchestrator inject the variable at runtime. The OWASP Secrets Management Cheat Sheet warns against hardcoding secrets with Docker ENV or ARG, because they leak with the container definition, and notes that environment variables can end up in logs or system dumps. Add .env to .gitignore.
Rotation. The documented sequence is create, deploy, delete: create a new key, update every application, then delete the old one. Revoked keys stop working immediately, so wait until the old key's last-used date stops moving before you delete it. For rotation without restarts in Python, api_key_auth also accepts a callable that the SDK resolves on each request, so it can return whatever key your secrets agent last wrote.
Ownership. In an organization, Owners and Admins see every key, while Developers see only their own. Because removing a member deletes their keys, create production keys under an account that will outlive any one engineer, and replace keys before offboarding.
Logs. The Python SDK's debug logging (YOU_DEBUG=1) redacts Authorization and X-API-Key but not request or response bodies. Your own HTTP logging and proxies have no such filter, and curl -v prints every request header, key included, to stderr. Never paste verbose output into an issue or a chat thread.
How does MCP authentication differ from the REST API?
The MCP server is a separate endpoint with a different credential. Per the MCP server docs, remote connections send Authorization: Bearer <YDC_API_KEY>, not X-API-Key, or use OAuth 2.1. A client that implements MCP Authorization connects without a key, receives a 401 with a WWW-Authenticate header pointing at the You.com authorization server, opens a browser for sign-in, and retries with the issued token.
| REST APIs | Remote MCP (api.you.com/mcp) | Local MCP package (npx @youdotcom-oss/mcp) | |
|---|---|---|---|
| Credential | X-API-Key header | Authorization: Bearer with your key, or OAuth 2.1 | YDC_API_KEY environment variable |
| Keyless option | Per-call machine payments on GET /v1/search, /v1/agents/search, and POST /v1/finance_research | ?profile=free: you-search and you-discover, 100 queries per day | YDC_PROFILE=free, same ceiling |
| OAuth | Not documented | Supported on compatible clients | Not supported |
| No credentials sent | 401, or a 402 challenge on payment-enabled endpoints | 401 with WWW-Authenticate when OAuth is enabled; listed tools can still fail at call time | Requests fail |
Two MCP behaviors complicate debugging. Authentication does not choose the tool list, so a tool can appear in your client and still fail at call time without credentials. And you-balance always requires a user API key, even when payment headers are present. When an MCP config file holds a literal Bearer key, treat that file like a .env file and keep it out of version control. The Exa MCP comparison shows how the two servers' credential models differ in practice.
The free profile is an evaluation path: the authentication docs say to use an API key for everything beyond evaluation. Machine payments suit agents you cannot provision a credential for, but Zero Data Retention is not available on keyless requests.
What should you test before you ship?
- Startup: the process exits if
YDC_API_KEYis unset or blank after stripping. - Deploy canary: one authenticated request from the deployed environment, not your laptop, that asserts a 200.
- Fixtures: parser tests for every documented body above, including both 402 shapes and a 500 that is not JSON.
- Alerts: count 401, 402, and 403 separately from 429 and 5xx. A jump in 401s after a deploy or an offboarding points to a deleted or stale key, 402 points to credits, and 403 points to scope or host.
- Balance: poll the Account Balance endpoint, which returns cents, or enable Auto Top-Up, so an empty balance never surfaces first as user-facing 402s. That endpoint documents its own 403 for keys that lack scope for it.
- Rotation drill: in staging, create a key, deploy it, confirm its last-used date moves, delete the old key, and confirm the old key now returns 401.
- Redirects: confirm your HTTP client does not follow redirects with the key attached.
Example verification: The shell script passed bash -n and ran against a local echo server with curl 8.17. The Python client passed py_compile and fixture tests on Python 3.9 for every documented status above, plus redirect refusal. The proxy passed node --check and an end-to-end test on Node 24 covering key injection, the parameter allowlist, error mapping, and redirect refusal. Every test ran against localhost mocks shaped like the documented responses, with no live API calls and no real keys, so run your own authorized test before relying on these examples in production.
LI Test
LI Test
Share Article:
Related resources.

How to Use the You.com Web Search API in TypeScript
September 22, 2026
Blog

How to Build a News Search Pipeline With the You.com Web Search API
September 22, 2026
Blog

What Is the You.com Web Search API? Endpoint, Pricing, and Limits
September 22, 2026
Blog

How to Use Date Filters With the You.com Web Search API
September 21, 2026
Blog

How to Call the You.com Web Search API With cURL
September 21, 2026
Blog
