OAuth discovery for AI agents: RFC 9728 and RFC 8414
OAuth discovery for AI agents: a 401 points to RFC 9728 resource metadata, then RFC 8414 server metadata. 2 JSON files, 8 pitfalls, and when to skip it.

OAuth discovery for AI agents is a chain of two metadata documents: a protected API answers 401 and points to its RFC 9728 resource metadata, which names an authorization server, whose RFC 8414 metadata lists the endpoints the agent needs. If nothing on your site needs an access token, there is nothing to discover, and you should publish neither document.
This is the layer after making your site agent-callable. An MCP Server Card or an API catalog tells an agent where your API lives, not how to sign in. A protected API has to answer that in a form a program can read. Support is up to each client: MCP makes it mandatory for MCP clients, but another agent may use credentials a person configured instead.
Key takeaways
- The chain: a
401withWWW-Authenticate: Bearer resource_metadata="…", then/.well-known/oauth-protected-resource(RFC 9728, April 2025), then/.well-known/oauth-authorization-server(RFC 8414, June 2018) or OpenID Connect Discovery.- Each document names itself (
resource,issuer). If the value isn't identical to the identifier the URL was built from, the client MUST NOT use the data.- MCP's authorization spec (revision 2026-07-28, current on 2 October 2026) requires servers that use authorization to implement RFC 9728 (MCP) and clients to refuse an authorization server without PKCE (security considerations).
- Public read-only sites, first-party logins, and API-key APIs need neither document. Faking them is worse than a clean
404.- Our own observation from aiSiteReady's check code: both OAuth checks become not applicable when no OAuth signal is found, and a bare MCP card isn't one.
What is OAuth discovery, and why do AI agents need it?
OAuth discovery is how a client learns, from the server itself, where to get a token: RFC 9728 describes the protected resource, RFC 8414 the authorization server. An agent at a protected endpoint has no browser tab, docs page, or onboarding email, only an HTTP response, so that response must carry the next step. The resource document lists its identifier, trusted authorization servers, and scopes; the server document lists its issuer, endpoints, and capabilities (RFC 9728, RFC 8414).
RFC 9728 spells out the sequence. The client calls without a token. The resource answers with a WWW-Authenticate header carrying the metadata URL. The client validates that metadata, builds the authorization server's metadata URL from a listed issuer, fetches it, completes the flow, and retries with the token (RFC 9728, Section 5).
Step 1 is the header on the 401. It also carries a scope parameter (RFC 6750, Section 3), which MCP servers SHOULD include (wrapped for display):
GET /mcp HTTP/1.1
Host: api.example.com
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp",
scope="scans:read"
RFC 9728 defines one new parameter, resource_metadata, and its own example is a bare Bearer resource_metadata="…" (RFC 9728, Section 5.1). MCP accepts the header, a well-known URI, or both. Clients must support both and prefer the header's URL (MCP, discovery).
What goes in the protected resource metadata (RFC 9728)?
One field is required, resource. The rest are optional or recommended in the RFC, and that is where MCP is stricter (RFC 9728, Section 2).
| Field | RFC 9728 status | What it tells the agent |
|---|---|---|
resource | REQUIRED | The resource identifier: an https URL with no fragment |
authorization_servers | OPTIONAL (MCP: required, at least one) | Issuers that can issue tokens for this resource |
scopes_supported | RECOMMENDED | Scope values used to request access |
bearer_methods_supported | OPTIONAL | How a token may be sent: header, body, or query |
resource_name | RECOMMENDED | Human-readable name |
The MCP specification says the document MUST include authorization_servers with at least one entry, and that clients send tokens in the Authorization header, never the query string (MCP, discovery, MCP, authorization). So ["header"] is the honest bearer_methods_supported for an MCP server. A minimal document for https://api.example.com/mcp:
{
"resource": "https://api.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["scans:read", "scans:write"],
"bearer_methods_supported": ["header"],
"resource_name": "Example API"
}
It lives at https://api.example.com/.well-known/oauth-protected-resource/mcp. The suffix goes between host and path, after removing any terminating slash that follows the host. A path-less identifier uses the bare /.well-known/oauth-protected-resource.
What goes in the authorization server metadata (RFC 8414)?
RFC 8414 requires issuer and response_types_supported. The endpoints are required unless no supported grant uses them (RFC 8414, Section 2).
| Field | RFC 8414 status | Why an agent cares |
|---|---|---|
issuer | REQUIRED | https URL, no query or fragment; the identity the document must match |
authorization_endpoint | REQUIRED unless no grant uses it | Where the user approves access |
token_endpoint | REQUIRED unless only implicit | Where the code becomes a token |
response_types_supported | REQUIRED | ["code"] for the authorization code flow |
grant_types_supported | OPTIONAL | Omitted means ["authorization_code", "implicit"] |
scopes_supported | RECOMMENDED | Scopes the agent may request |
registration_endpoint | OPTIONAL | Dynamic Client Registration (RFC 7591) |
code_challenge_methods_supported | OPTIONAL | Omitted means no PKCE support |
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"client_id_metadata_document_supported": true,
"scopes_supported": ["scans:read", "scans:write"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}
Two details matter. code_challenge_methods_supported is the only way a client learns about PKCE support. And agents are usually public clients: none is the RFC 7591 value for that, while an omitted token_endpoint_auth_methods_supported defaults to client_secret_basic. Keep registration_endpoint only if you still run Dynamic Client Registration.
Where does the document live when the issuer has a path?
For an issuer of https://auth.example.com/tenant1, the document lives at /.well-known/oauth-authorization-server/tenant1: RFC 8414 inserts the suffix between the host and the issuer's path. OpenID Connect Discovery appends it instead. RFC 8414 Section 5 documents the difference and allows publishing at both locations during a transition (RFC 8414). MCP clients try, in order: /.well-known/oauth-authorization-server/tenant1, /.well-known/openid-configuration/tenant1, /tenant1/.well-known/openid-configuration (MCP, discovery).
Serve both documents as 200 OK with Content-Type: application/json; each RFC requires it. RFC 9728 adds that normal HTTP caching applies and suggests Cache-Control with max-age (RFC 9728, Section 7.10). Neither RFC mentions CORS, but a browser-based client reading the documents cross-origin needs Access-Control-Allow-Origin (MDN). For public metadata without secrets, * is a reasonable choice. That is my recommendation, not a requirement. On nginx:
location = /.well-known/oauth-protected-resource/mcp {
default_type application/json;
add_header Cache-Control "public, max-age=3600" always;
add_header Access-Control-Allow-Origin "*" always;
alias /srv/metadata/oauth-protected-resource.json;
}
How does MCP authorization use these documents?
MCP requires this chain from every HTTP server that uses authorization: the server publishes RFC 9728 metadata, its authorization server publishes RFC 8414 or OpenID Connect metadata, and clients follow both. Authorization itself stays optional. Revision 2026-07-28 is listed as current on the versioning page as of 2 October 2026 and sets these rules (MCP, authorization).
| Role | Requirement in revision 2026-07-28 |
|---|---|
| MCP server | MUST implement RFC 9728, with authorization_servers listing at least one server |
| MCP server | MUST advertise it through resource_metadata in a 401, or at a well-known URI |
| Authorization server | MUST provide RFC 8414 metadata or OpenID Connect Discovery |
| MCP client | MUST support both mechanisms; header URL first, else well-known URIs |
| MCP client | MUST reject metadata whose issuer differs from the identifier used to build the URL |
| MCP client | MUST verify PKCE from code_challenge_methods_supported and refuse if absent; MUST use S256 when capable |
Sources: discovery and security considerations. The PKCE row is why code_challenge_methods_supported isn't decoration: omit it and a compliant client stops before the user sees a consent screen.
Registration changed between revisions. In 2025-11-25, Client ID Metadata Documents were a SHOULD and Dynamic Client Registration (RFC 7591) a MAY kept for compatibility (2025-11-25). Revision 2026-07-28 deprecates Dynamic Client Registration and points new implementations at Client ID Metadata Documents, advertised with client_id_metadata_document_supported: true. Servers that still run Dynamic Client Registration keep advertising it through registration_endpoint (MCP, client registration).
OAuth 2.1 and Client ID Metadata Documents were still Internet-Drafts on 2 October 2026 (revisions 16 and 02; MCP cites mostly 13 and 00), so this part may change again.
The Server Card guide covers where the MCP endpoint is and which transport it speaks. The OAuth documents say how to get a token for it. Publish the card either way, and the OAuth documents only if the server is protected.
What mistakes break OAuth discovery?
Most failures are an identifier that doesn't match or a response that looks like success but isn't.
| Mistake | What goes wrong | Fix |
|---|---|---|
issuer differs from the issuer behind the URL (trailing slash, http, a proxy rewriting the host) | Clients must not use it (RFC 8414 Section 3.3); aiSiteReady reports issuer_mismatch, scored PARTIAL | Build issuer and the URL from one configured value |
resource missing, or not the URL the client asked for | Required (RFC 9728 Section 2); a mismatch must not be used (Section 3.3) | Emit the canonical resource URL |
| Endpoints that 404 or redirect to a login page | Discovery works, then the flow dies at authorization_endpoint or token_endpoint | curl every URL you list; drop registration_endpoint if you don't run it |
200 with an HTML page for a missing well-known URL (single-page-app fallback) | A soft 404: it looks like success, then fails JSON parsing. The scanner records an unsupported media type, never metadata | Serve the JSON before the fallback route; return a real 404 when there is nothing to publish |
Wrong Content-Type such as text/plain | Both RFCs say application/json; the scanner flags unexpected_content_type on resource metadata | Set the type explicitly, as in the nginx block |
code_challenge_methods_supported absent | Means no PKCE; MCP clients refuse to proceed | Advertise ["S256"], but only if the server enforces PKCE |
code_challenge_methods_supported without S256 (only plain, say) | MCP clients must use S256 when they can; aiSiteReady scores the server PARTIAL | Support and advertise S256 |
Suffix appended for oauth-authorization-server | Not found: RFC 8414 inserts, only OpenID Connect Discovery appends | Follow the lookup order above |
Exact means exact. RFC 8414 compares the two strings code point by code point, with no normalization, so https://auth.example.com and https://auth.example.com/ are different values (RFC 8414, Section 4). aiSiteReady currently expects a root issuer without a trailing slash, so a provider that uses one (Auth0 publishes https://<tenant>.auth0.com/) is reported as issuer_mismatch, even though a client that built the URL from that exact string accepts it.
How do you verify your OAuth discovery?
Verify from outside, the way an agent does: one request for the challenge, one for each document, against the public URLs.
# 1. The challenge: does the protected endpoint point at its metadata?
curl -sS -D - -o /dev/null https://api.example.com/mcp | grep -i -E '^(HTTP|www-authenticate)'
# 2. The resource metadata: status, content type, body
curl -sS -D - https://api.example.com/.well-known/oauth-protected-resource/mcp
# 3. The authorization server named in authorization_servers
curl -sS -D - https://auth.example.com/.well-known/oauth-authorization-server
The first command should print something like this (wrapped for display):
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp",
scope="scans:read"
The other two should return 200 with application/json.
Then check the identities, where most real breakage hides. This CI smoke test starts from the resource URL, not the 401, then follows the chain: it applies the path-insertion rule, tries the OpenID Connect locations if the RFC 8414 one fails, and compares resource and issuer exactly:
#!/usr/bin/env bash
set -euo pipefail
RESOURCE="${1:?usage: check-oauth.sh https://api.example.com/mcp}"
origin() { sed -E 's#^(https://[^/]+).*#\1#' <<<"$1"; }
# urlpath drops a trailing slash, as the aiSiteReady scanner does; RFC 9728 keeps it
urlpath() { local p; p="$(sed -E 's#^https://[^/]+##' <<<"$1")"; echo "${p%/}"; }
PRM="$(curl -sSf -H 'Accept: application/json' \
"$(origin "$RESOURCE")/.well-known/oauth-protected-resource$(urlpath "$RESOURCE")")"
jq -e --arg r "$RESOURCE" '.resource == $r' <<<"$PRM" >/dev/null \
|| { echo 'FAIL: resource does not match the URL'; exit 1; }
ISSUER="$(jq -er '.authorization_servers[0]' <<<"$PRM")"
AS="$(curl -sf -H 'Accept: application/json' \
"$(origin "$ISSUER")/.well-known/oauth-authorization-server$(urlpath "$ISSUER")" \
|| curl -sf -H 'Accept: application/json' \
"$(origin "$ISSUER")/.well-known/openid-configuration$(urlpath "$ISSUER")" \
|| curl -sSf -H 'Accept: application/json' \
"$(origin "$ISSUER")$(urlpath "$ISSUER")/.well-known/openid-configuration")"
jq -e --arg i "$ISSUER" '.issuer == $i' <<<"$AS" >/dev/null \
|| { echo 'FAIL: issuer mismatch'; exit 1; }
jq -e '.code_challenge_methods_supported | index("S256") != null' <<<"$AS" >/dev/null \
|| { echo 'FAIL: no S256'; exit 1; }
echo OK
Finally, the negative test, for a site with nothing to publish. A missing well-known URL should be a real 404, not a friendly page:
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
https://example.com/.well-known/oauth-authorization-server
# expected: 404 ... suspicious: 200 text/html (your app's fallback page)
Do you need OAuth discovery at all?
Often not. These documents describe an OAuth deployment, so without one there is nothing true to publish. Walk the tree before you write any JSON.
| Your situation | RFC 9728 document | RFC 8414 document |
|---|---|---|
| Public, read-only content site | Don't publish | Don't publish |
| Public API or MCP server with no sign-in | Don't publish | Don't publish |
| API keys or static tokens only | Don't publish; document the keys | Don't publish |
| First-party login for your own app only | Don't publish | Don't publish |
| Protected API, tokens from a hosted provider | Publish, naming the provider | The provider serves it; check its URL |
| Protected API, you run the authorization server | Publish | Publish |
Don't publish placeholder metadata to turn a check green. Both RFCs exist so a client can discard a document that doesn't match its identifiers. A made-up authorization_servers entry sends an agent to a server that will never issue it a token, and a 404 is more honest and cheaper to debug.
The opposite trap exists too: aiSiteReady reads a bare WWW-Authenticate: Bearer on the scanned URL as an OAuth signal. If you send that header, add the resource_metadata pointer. If you have no OAuth, don't send it.
How does aiSiteReady check OAuth discovery?
Two checks in aiSiteReady's Protocol/API category cover this article: OAuth protected resource (oauth_protected_resource) and OAuth authorization server (oauth_authorization_server). Both belong to the oauth_discovery scan option. The category is off for the Content site profile (content_site) and carries 40% of the score under the API/Application profile (api_application) and 20% under the default All checks profile. The exact checks and weights live on the methodology page.
For the authorization server, the scanner tries /.well-known/oauth-authorization-server and /.well-known/openid-configuration, adds the insertion and appending variants when the scanned URL has a path, and follows Link headers on the scanned URL with the relations oauth-authorization-server or openid-configuration. Neither RFC defines those relations; the scanner accepts them as a hint.
For the resource, it tries the path-inserted and the bare well-known URL, then makes one anonymous request and reads the WWW-Authenticate challenge. As an SSRF guard it follows a resource_metadata URL only if it is HTTPS, on the same host and port, and under /.well-known/oauth-protected-resource. Then it fetches each issuer in authorization_servers, up to five, at the RFC 8414 and OpenID inserted locations (not yet the appended one), through the SSRF-safe fetcher. No remote JavaScript runs.
How does the scanner score each document?
A missing or invalid authorization_servers makes the resource document invalid, which is stricter than RFC 9728 and matches MCP. A wrong Content-Type is recorded as evidence and doesn't change the status; a missing scopes_supported keeps the document usable for the issuer chain but caps the result at PARTIAL. An agent-ready server is RFC 8414-valid, supports the authorization code flow, and advertises S256 and scopes_supported.
| Status | Resource check | Authorization server check |
|---|---|---|
| PASS | Valid document, no resource mismatch, at least one agent-ready server, scopes_supported present | At least one agent-ready server |
| PARTIAL | Found but: resource mismatch, no scopes, server incomplete or unvalidated, or invalid | Valid but no S256 or no scopes; issuer mismatch; or invalid |
| FAIL | Nothing found, and an OAuth signal is present | Same |
| N/A | Nothing found, and no OAuth signal | Same |
| UNKNOWN | Fetch failed or was bot-blocked (timeout, 403, 429) | Same |
When does a missing document count as not applicable?
When I wired this into the scan pipeline, the reasoning was simple. A confident "publish RFC 8414 metadata" failure on a site with no OAuth is a false positive that costs score for something the site should not do. So one step runs after every check and rewrites only the "nothing found" FAIL, and only when nothing signals OAuth. N/A then drops out of the score's denominator.
The signals are the API/Application profile, a WWW-Authenticate: Bearer header on the scanned URL, a Link header there with oauth-authorization-server or openid-configuration, and protected resource metadata or any Bearer challenge found by the resource check.
One consequence: the API/Application profile itself counts as a signal, so under it a missing document stays FAIL even on an API-key API. If you don't run OAuth, scan with the default profile or leave the OAuth discovery option out of a custom scope.
A bare MCP card, A2A card, or API catalog is deliberately not a signal, because an agent surface can run on API keys. PASS, PARTIAL, and UNKNOWN are never rewritten, so a real deployment still scores for what it publishes. Scan the resource's own URL: because resource must equal the scanned URL, a document for https://api.example.com/mcp found while scanning https://example.com scores PARTIAL with resource_mismatch, by design.
aiSiteReady publishes neither document. Its well-known routes serve the A2A card, the API catalog, the Agent Skills index, and the MCP Server Card, and nothing for OAuth. The public MCP server is documented with Auth: none and the REST API's rate limits. The optional accounts feature, off by default, is a first-party login with short-lived access tokens, not an authorization server third-party agents can register with.
On 3 October 2026 all four OAuth well-known URLs on www.ai-siteready.com returned 404 and the homepage sent no Bearer challenge, so neither check finds an OAuth signal: under the default profile both read N/A.
Run a free scan on your API or MCP URL to see which outcome each document gets, with the HTTP evidence behind it, in English, Ukrainian, or Russian. This guide is the sign-in half of making a site agent-callable; the API catalog guide covers how agents find the API first, and the 30-minute audit shows where this fits in a full pass. For the bigger picture, start with what AI agent readiness means.
The short version: if your API is protected, publish a 401 that points to RFC 9728 metadata, and an authorization server whose RFC 8414 metadata matches its issuer and advertises S256. If it isn't, publish nothing and let the 404 speak for you.
IMozz has 20 years in software development, with the past year spent building with LLMs. He builds aiSiteReady, a read-only scanner that checks whether AI agents can read and call a site. It server-renders its own content as a working example.