What a Client ID Metadata Document is: a client ID that is a URL
A Client ID Metadata Document (CIMD) is a JSON file an OAuth2 client publishes at an HTTPS URL,
and that URL is the client's client_id. The client never registers. It asserts an identity, and
your authorization server fetches the document behind that URL to read it. Nothing gets written to
your database, because there is nothing to write.
A month ago I published the DCR proxy pattern: a Spring Authorization Server that adds Dynamic Client Registration in front of an IdP that can't do it. That post ended on a promise. It called itself "a DCR broker today, with a deliberate CIMD seam," and said of the CIMD path: "None of that is wired yet."
This post wires it. Same demo, same Keycloak, same MCP server, in a sibling repo you can run with one command. And the switch itself is one line:
// RFC 7591 registration is switched OFF explicitly: the library enables
// its registration endpoint by default, and this server must expose none.
mcp -> mcp.cimd(true)
.dynamicClientRegistration(false)
.authorizationServer(authzServer -> {One line to flip. Everything worth reading here is what that line forces you to build around it.
The whole client identity is one small JSON file
Here is the demo client's entire identity. Not an excerpt, every field it has:
{
"client_id": "http://localhost/cimd-client/client.json",
"client_name": "spring-oauth2-mcp-cimd demo client",
"redirect_uris": ["http://localhost:6274/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}Look at the second line. The client_id is the URL this file is served from. The document points at
itself, and that self-reference is doing all the work.
An attacker can copy this document to a host they control, but the copy now sits at a different URL,
so its client_id field no longer matches where it lives. Your server rejects it. Only whoever
controls http://localhost/cimd-client/client.json can publish a valid document claiming to be that
client. Control of the URL is the credential, and that one property is why everything downstream
gets so small.
Why CIMD exists: your MCP server has to accept clients it has never met
Classic OAuth2 pre-registration assumes a human visits a portal, fills in a form, and gets a
client_id and secret before any code runs. That works fine when you know every client in advance.
An MCP server usually doesn't. Someone installs your server in an editor you've never heard of, and
that editor needs an OAuth2 identity before it can ask for a token.
DCR's answer was to let the client register itself at runtime. It works, and it costs you two things: a live registration endpoint that anyone can POST to, and a database row per client instance. My June repo needed a whole proxy in front of Keycloak precisely because most enterprise IdPs won't expose that endpoint at all.
The MCP revision of 2025-11-25 (the change landed as SEP-991) made CIMD a SHOULD and dropped DCR to MAY, and the reasoning is operational rather than cryptographic. On a public server, every client that ever connects writes a row, including the ones that connect once and vanish. Each row is a credential-shaped object you now own and have to expire, there's no natural garbage collection, and the write endpoint itself is an abuse target.
CIMD's answer is to skip the handshake and put identity where it's already published and already owned: on the vendor's own domain. The client already has an identity, because a URL is an identity. Your server just has to be able to read. The client identity database is the web.
DCR gives you a row you have to keep. CIMD gives you a URL you have to trust.
Worth being blunt about when you don't need any of this, though: if you only ever talk to clients you control, pre-register them and move on. Neither mechanism earns its keep. CIMD starts paying the moment you're open to clients you don't control.
What the authorization server does at authorize-time
An MCP client sends /oauth2/authorize?client_id=http://localhost/cimd-client/client.json&... and
your server takes over from there. Five steps, and the
javadoc in the repo
names all of them:
- Detect. The
client_idis URL-shaped, so the CIMD repository takes the request instead of the JDBC one. - Fetch. It GETs the Client ID Metadata Document from that exact URL.
- Validate. The document's own
client_idmust equal the URL it came from,redirect_urismust be present and valid, and aclient_secretis not allowed. - Convert. What comes back becomes a public PKCE
RegisteredClient. - Cache. Kept for the TTL in the response's
Cache-Control: max-age, or five minutes when the header says nothing.
After that fifth step the normal authorization-code flow runs as if this client had been registered
for years, because as far as Spring Authorization Server is concerned, it now is a
RegisteredClient like any other. It just happens to be one that will evaporate in five minutes,
and its redirect_uri is matched exactly against the list in the document that just arrived.
The three kinds of "auth" people conflate here
This is where CIMD gets misread, so it's worth separating three questions that sound like one.
Is the client authenticated? No. Not at all. The document says
"token_endpoint_auth_method": "none", which makes this a public client with no secret. There's
nothing to authenticate with, and that's deliberate: a client that ships to thousands of laptops
can't hold a secret anyway. What carries the weight instead is PKCE, plus the exact match of the
request's redirect_uri against the list in the fetched document. A hostile party can start a flow.
They can't get the code delivered anywhere the real client's document doesn't already name.
Is the user authenticated? Yes, and this half didn't change at all. The login federates upstream to Keycloak exactly as it did in the DCR repo:
.oauth2Login(login -> login.loginPage(upstream.authorizationRequestUri()))One wrinkle worth knowing: the authorization server's own consent page never renders. MCP clients send scopeless authorization requests, and the library skips consent for those, so the only consent screen a user sees is Keycloak's.
What may the token do? Whatever the user's roles allow, and nothing the client asked for. Scopes get stamped at mint time from the upstream realm roles. The comment on the fail-closed branch got sharper under CIMD:
// FAIL CLOSED: if we left the claim alone, Spring Authorization Server
// would stamp the client's REQUESTED scopes, and under CIMD the client
// authors its own metadata document, so requested scopes are
// attacker-suppliable. No mappable roles ⇒ no scopes.
context.getClaims().claim(OAuth2ParameterNames.SCOPE, Set.of());That line is byte-identical to the DCR repo. Only the reason changed: under DCR the client asked your server for scopes, and under CIMD it writes its own metadata file and declares them. The threat got closer, the defence didn't have to move.
Wiring it in Spring: the types exist, the autoconfig doesn't
Before the wiring, the trap. Turning DCR off is one line, and it isn't the line you'd guess.
.cimd(true) doesn't disable anything. Open the configurer's source and the two flags are
independent fields with opposite defaults:
/**
* Enable or disable dynamic client registration (DCR).
* @param enabled turn on DCR when true, off otherwise. Defaults to true.
*/
public McpAuthorizationServerConfigurer dynamicClientRegistration(
boolean enabled) { ... }
/**
* Enable or disable support for Client ID Metadata Document.
* @param enabled turn on CIMD when true, off otherwise. Defaults to false.
*/
public McpAuthorizationServerConfigurer cimd(boolean enabled) { ... }DCR defaults to on. .cimd(true) flips a separate flag that advertises CIMD support and mounts the
resolution path; it leaves the registration endpoint exactly where it was. The only off-switch is
.dynamicClientRegistration(false), which is why that call sits in the opening snippet doing real
work rather than stating the obvious. Grep the Java in this repo for dynamicClientRegistration and
you get exactly one hit: the line switching it off.
Past that, there's no Boot-style autoconfiguration for CIMD yet (mcp-security#24 tracks that), so you wire three types by hand:
final var cimdRepository =
new ClientIdMetadataDocumentRegisteredClientRepository();
cimdRepository.setMetadataDocumentResolver(
new DefaultClientIdMetadataDocumentResolver(
cimdMetadataRestClient(cimdFetchAddressFilter)));
// DEMO ONLY: the CIMD draft requires https client_ids and the library
// enforces that by default.
cimdRepository.setClientIdUrlValidator(new ClientIdUrlValidator(true));
cimdRepository.setMetadataValidator(
new DefaultClientMetadataValidator(new DefaultUrlValidator(true)));
// Pre-registered clients (none in the demo); also the save target.
final var jdbcRepository = new JdbcRegisteredClientRepository(jdbcOperations);
final var delegating = new DelegatingRegisteredClientRepository(
List.of(cimdRepository, jdbcRepository), jdbcRepository);The delegating repository tries CIMD first, then falls back to JDBC for pre-registered clients. The demo ships none, so that table stays empty, which is the point.
One thing that will confuse you if you go looking for the source. The import reads like core Spring Security:
import org.springframework.security.config.annotation.web.configurers
.oauth2.server.authorization.client.metadata
.ClientIdMetadataDocumentRegisteredClientRepository;It isn't. Unzip mcp-authorization-server-0.1.13.jar and that class is inside it. The community
project ships classes into Spring Security's own package namespace, which is a normal trick for
reaching package-private API and a confusing one to land on from the import statement. Search the
Spring Security repo for that class and you'll find nothing.
The bug that only shows up five minutes in
Here's the one I'd want someone to tell me about, because the failure lands nowhere near the cause.
The library's findById only consults its cache. That cache holds a document for five minutes.
Refresh tokens in this demo live seven days. So a refresh-token grant made six minutes after the
original authorization asks for a client the repository has already forgotten, gets null, and
fails. Same for any restart while a persisted authorization is replayed. Three lines fix it:
@Override
public RegisteredClient findById(String id) {
var client = delegating.findById(id);
// For CIMD clients the library sets id == client_id == the metadata
// URL, but its findById only consults the short-TTL cache. A miss on
// a URL-shaped id (TTL elapsed, or a restart while a JDBC-persisted
// authorization is replayed, e.g. a refresh-token grant) is
// re-resolved through the full fetch-and-validate path.
if (client == null
&& (id.startsWith("http://") || id.startsWith("https://"))) {
client = delegating.findByClientId(id);
}
return applyDefaults(client);
}Nothing in the docs mentions this. It's the kind of thing you only meet by leaving a demo running past the coffee break.
The SSRF risk: your server now fetches a URL an attacker picked
Under DCR your authorization server made no outbound calls to identify a client. Under CIMD it
dereferences a URL supplied by whoever is starting the flow, which is a textbook server-side request
forgery vector: point it at 169.254.169.254 and it reads your cloud metadata endpoint for you.
The guard is one bean:
@Bean
InetAddressFilter cimdFetchAddressFilter(Environment environment) {
// Filter semantics: matches == allowed. externalAddresses() = routable
// minus multicast, RFC 6890 special-purpose, and the
// private/link-local/loopback ranges listed above.
final var externalOnly = InetAddressFilter.externalAddresses();
if (environment.matchesProfiles("demo")) {
// DEMO ONLY: allow loopback so the locally-served metadata
// document is fetchable.
return externalOnly.or("127.0.0.0/8", "::1/128");
}
return externalOnly;
}Boot 4.1's InetAddressFilter gets enforced inside the connection manager's DNS resolver, so it
vets the addresses actually being connected to, redirect hops included. A refused host throws
FilteredHostException before any socket opens. The offline test drives the real production client
construction rather than the filter in isolation, so it proves the wiring and not just the
predicate:
final var restClient =
OAuth2StoreConfiguration.cimdMetadataRestClient(strictFilter());
assertRefused(() ->
restClient.get().uri("http://10.255.255.1/client.json")
.retrieve().toEntity(String.class));
assertRefused(() ->
restClient.get().uri("http://localhost:1/client.json")
.retrieve().toEntity(String.class));What statelessness actually buys you
"Stateless" gets said a lot and cashed out rarely, so here it is in four concrete pieces, each one visible in the running demo.
Nothing accumulates
The oauth2_registered_client table in this repo is real, it's indexed, and it's permanently empty.
That's the feature, and one query is the whole argument:
SELECT COUNT(*) FROM oauth2_registered_client;
-- 0Ten thousand clients cost exactly what one client costs, because the storage cost is zero either way. No pruning job for stale clients, no migration when a client's metadata changes, no capacity question.
The tempting cleanup is to delete that schema entirely, and it was kept on purpose. Authorizations and consents still need JDBC persistence so refresh tokens survive a restart, so the Liquibase changelog stays exactly as it was in the DCR repo. The registered-client table just never fills up, which makes it the most legible proof of the whole thesis. The schema comment says it plainly:
<!-- These tables back JdbcRegisteredClientRepository,
JdbcOAuth2AuthorizationService, and
JdbcOAuth2AuthorizationConsentService.
CIMD clients are resolved from the metadata document behind their URL
client_id and are never written to oauth2_registered_client.
What accumulates here are authorizations and consents:
SELECT registered_client_id, principal_name FROM oauth2_authorization; -->One less write path exposed
No registration endpoint means no unauthenticated POST that creates persistent records on your server. But not advertising an endpoint isn't the same as not having one, and it's worth being careful about how you prove the difference.
Leaving registration_endpoint out of the metadata only means well-behaved clients won't go looking
for it. A 403 from POST /oauth2/register doesn't settle it either, tempting as that is: a
registration endpoint that merely sits behind authentication would return 403 too. The 403 tells
you the request was refused, not that the route is absent.
What does settle it is the differential. Probe the path against a route you know doesn't exist, and against one you know does:
| Path | POST | GET |
|---|---|---|
/oauth2/register | 403 | 302 |
| a path that was never mapped | 403 | 302 |
/oauth2/jwks, a real endpoint | n/a | 200 |
/oauth2/register answers identically to a path that isn't there and nothing like a path that is.
That's the claim worth making, and it's narrower than the one I reached for first: an earlier
version of the repo's check task read that bare 403 as proof the route was never mapped, which is
more than a 403 can carry.
No onboarding step for the client vendor
Nobody has to run a registration call, store a returned client_id, or handle the case where it got
lost. The vendor publishes one file and is done.
Identity updates propagate on their own
When a client vendor adds a redirect URI, they edit their document, and your server picks it up on the next cache miss. Under DCR that same change is either an RFC 7592 management call or a support ticket.
The other side of stateless: revocation, and someone else's uptime
Two costs come with that, and neither should be soft-pedalled.
Revocation has no obvious lever. Under DCR, cutting off a client is a DELETE on a row. Under
CIMD there is no row. You can't revoke a client, because you never granted it anything. All you can
do is stop accepting its URL, which quietly makes your trust policy the revocation mechanism. The
demo ships without one, and that turns out to be its largest gap; more on that below.
You've taken a runtime dependency on a third party's host. This is the trade people miss. DCR's
state was a liability you owned. CIMD swaps it for an availability question you don't own: if the
client vendor's document host is slow or down at the moment a user logs in, the login fails. Your
fetch timeout, your cache TTL, and your behaviour on a stale cache entry stop being tuning knobs and
become availability decisions. The demo caches for five minutes because nginx sends no
Cache-Control on client.json, and the library defaults to five minutes when the header is
absent.
| DCR | CIMD | |
|---|---|---|
| Client state | a row per client, forever | none; a short-lived cache entry |
| Registration endpoint | live, unauthenticated POST | none; POST /oauth2/register returns 4xx |
| Scale | storage grows with client count | flat |
| Revocation | delete the row | no row; your trust policy is the lever |
| Metadata updates | RFC 7592 call or support ticket | vendor edits the document |
| Outbound dependency | none | the client's host, on every cache miss |
| New attack surface | open registration | SSRF via the fetched URL |
What changed between the two repos, and what didn't
The two repos are the same demo with one mechanism swapped, so the diff is the evidence for everything above. The surprise is how little of it there is.
The entire mcp-server module is byte-for-byte identical between them: every tool, every
@PreAuthorize, the RFC 9728 metadata, the ES256 validation, the audience check. So is the whole
Keycloak setup. On the authorization-server side, one config class was substantially rewritten
(OAuth2StoreConfiguration), one DSL line flipped, one guard bean and its test were added, and the
rest of the changes are comments. The JWKS config, the token claims config, and the role mapper have
zero changed lines; the scope mapper's only diff is the sharpened comment you read above. Swapping
how clients identify themselves touched neither the resource server nor the IdP, which is the
strongest case I can make that CIMD is a swap and not a rebuild.
What it prints when it runs
One command builds both modules, starts the infrastructure, provisions the Keycloak realm, runs the discovery checks, and then drives the scope matrix live:
mise run demoThe checks confirm the shape of the server: CIMD advertised, no registration endpoint, ES256, and the MCP server's 401 pointing at its Protected Resource Metadata.
AUTHORIZATION SERVER (http://localhost:9000)
[ OK ] CIMD advertised (client_id_metadata_document_supported: true)
[ OK ] no registration_endpoint in AS metadata
(clients bring a URL client_id instead)
[ OK ] POST /oauth2/register refused (HTTP 403)
[ OK ] ES256 advertised in OIDC metadata
[ OK ] JWKS serves an EC P-256 key
MCP SERVER (http://localhost:9200)
[ OK ] unauthenticated /mcp → 401
[ OK ] 401 carries WWW-Authenticate → resource_metadata
[ OK ] Protected Resource Metadata points at the authorization serverThen the headless end-to-end test authorizes with a URL where a registration call used to be, logs both users in through Keycloak, and calls the tools:
CIMD client "spring-oauth2-mcp-cimd demo client" —
URL client_id: http://localhost/cimd-client/client.json
=== alice (user) ===
token scopes: [note:read, note:write]
✓ ALLOW whoami
✓ ALLOW list_notes
✓ ALLOW create_note
✗ DENY purge_notes (Error invoking method: purgeNotes
Access Denied)
identity: username=alice email=alice@example.com note.author=alice
PASS: alice (user)
=== bob (admin) ===
token scopes: [note:admin, note:read, note:write]
✓ ALLOW whoami
✓ ALLOW list_notes
✓ ALLOW create_note
✓ ALLOW purge_notes
identity: username=bob email=bob@example.com note.author=bob
PASS: bob (admin)
ALL E2E CHECKS PASSED ✅Alice's scopes come from her user realm role, bob's admin role adds note:admin, and neither
client asked for any of it. That's the demo working exactly as it did under DCR, with the client
identity resolved from a URL instead of a database row.
Which is fine, but it's my own client talking to my own server. The more interesting question is what happens when a client I didn't write shows up.
Who hosts these documents in production
The vendor does, and you never do. Whoever ships the client publishes its document on their own domain, and your authorization server dereferences that URL at authorize time. You host nothing for clients you have never met, which is most of the operational appeal. The nginx container in this repo exists only because the end-to-end test plays the client role locally, and something has to serve its file.
| Thing | Who hosts it |
|---|---|
| The client metadata document | the client vendor, on their own domain |
| The MCP server | you |
| The authorization server | you |
| The outbound fetch and its SSRF guard | you |
Which also means an outage on their host is an outage on your login path.
Claude Code: the gap between a published document and a running client
Claude Code publishes a metadata document at https://claude.ai/oauth/claude-code-client-metadata.
So I pointed the server at it, expecting the flow to just work. Instead I got a 400, and the
reason came down to a single hostname form.
Everything stated here is as of 2026-07-25 with Claude Code 2.1.219. Both the callback template and the environment variables below can change in a patch release.
The document declares its loopback callbacks without a port:
"redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"]At runtime, Claude Code binds a random port and sends http://localhost:${randomPort}/callback.
That URI is not in the document, so a spec-strict server refuses it. Probing the authorize endpoint
with Claude Code's real client_id and each candidate redirect URI in turn:
redirect_uri sent | result |
|---|---|
http://127.0.0.1/callback | 302 → Keycloak |
http://127.0.0.1:54321/callback | 302 → Keycloak |
http://localhost/callback | 302 → Keycloak |
http://localhost:54321/callback (what it actually sends) | 400 |
https://evil.example/steal | 400 |
Look at rows two and four. An arbitrary port is fine on 127.0.0.1 and rejected on localhost, and
that asymmetry is deliberate. RFC 8252 §7.3 lets a native app use any port on a loopback redirect,
but the rule is written for the IP literals 127.0.0.1 and [::1]. The name localhost gets the
opposite treatment one section over: §8.3 recommends against it outright, because a name can be
repointed by a hosts file or by DNS and a loopback address literal cannot. Spring Authorization
Server implements exactly that distinction, so the server is right and the refusal is the security
property working.
Worth noticing what did not fail. The CIMD half was flawless throughout: the server fetched Anthropic's document through the SSRF guard, validated it, and accepted the identity every time. Only the shape of the redirect URI was ever in question.
And there's a way through, which is the part I'd have wanted to know first. Claude Code reads
MCP_OAUTH_CLIENT_METADATA_URL and MCP_OAUTH_CALLBACK_PORT, so you can point it at a document
whose redirect_uris match what it actually sends, on a port you pin:
{
"client_id": "http://localhost/cimd-client/claude-code.json",
"client_name": "Local identity document for Claude Code (demo)",
"redirect_uris": [
"http://localhost:3118/callback",
"http://127.0.0.1:3118/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}With that document and a matching MCP_OAUTH_CALLBACK_PORT, the whole chain runs: Claude Code
authorizes as a CIMD client, I logged in through Keycloak, the server minted a real ES256 token, and
the scope-gated tools answered according to my roles. A client I did not write, identifying itself
with nothing but a URL, ran the whole flow.
None of this is a complaint about Anthropic. A published document and a runtime template disagree by one hostname form, the RFC sides with the server, and there's a documented way through. It's what early adoption of a draft actually looks like: the standard works, the fleet hasn't caught up yet, and the gap is smaller than it sounds.
Where this breaks
The rough edges, in the order they'd bother me.
There's no trust policy, and that's the biggest gap between this demo and production. Every syntactically valid document from an allowed address is accepted. Deciding which client URLs you honour, through an allowlist or domain pinning or something with a human in it, is the piece you'd have to build, and it's the piece that turns "anyone can start a flow" into a decision you made rather than one you inherited.
The MCP Inspector sits in the same category as the Claude Code mismatch, one notch further back. It still obtains its identity via DCR, so against a CIMD-only server it can complete discovery and then has nowhere to go:
MCP Inspector and CIMD — the honest status:
At the time of writing (mid-2026) the MCP Inspector still obtains its
client identity via Dynamic Client Registration (RFC 7591): it POSTs to a
registration endpoint instead of presenting a URL client_id. This server
supports only CIMD and exposes NO registration endpoint, so the Inspector
can discover the authorization server but cannot complete the OAuth flow
against it.The spec says SHOULD, this server implements the SHOULD, and the protocol's own reference client still speaks the MAY. Claude Code is one hostname form away; the Inspector hasn't started.
CIMD is an IETF draft, not an RFC. The MCP 2025-11-25 spec normatively references
draft-ietf-oauth-client-id-metadata-document-00; the working-group draft has since moved to -02
(dated 2026-07-06) and is still active. You're building on a moving target, and I'd rather say that
than imply stability that isn't there.
Per-client consent is still missing, exactly as it was in the DCR repo. Every CIMD client rides the same federated Keycloak consent, so the confused-deputy problem the MCP spec names applies here unchanged. A shared upstream client with no per-client prompt is the first thing I'd fix before this went anywhere real.
The demo also relaxes things a production server must not. The CIMD draft requires HTTPS client IDs,
so the local stack turns on the library's loopback exception to serve its document over plain HTTP,
and the SSRF guard allows loopback back in under the default demo profile. Any other profile
restores the strict filter, and both shortcuts are marked DEMO ONLY where they live.
Then the small one that will bite someone: the metadata URL is the client_id, so if you change
WEB_PORT in support/.env, you've changed the client's identity and have to edit client.json to
match.
CIMD is the SHOULD; DCR is still the answer for most people today
If you're deciding between them right now, the honest read is that this isn't a migration you're behind on. CIMD is where the spec points and it's genuinely simpler once it's running: no registration endpoint, no client table, no onboarding call. But the installed base is uneven in a way the spec text doesn't prepare you for. Anthropic's document resolved cleanly and then tripped on a hostname form, needing two environment variables to complete a flow, and the reference Inspector can't start at all because it still registers. A server that speaks only CIMD will turn away clients your users actually have.
So the DCR proxy from the June post isn't obsolete, and I'd still reach for it in front of an IdP that can't register clients. What changed is that the seam I left open there is a working reference now instead of a paragraph of intent, and I know exactly where it frays.
The piece I'd think hardest about isn't the code. CIMD quietly moves a dependency: your login path now runs through a URL somebody else operates, on their uptime and their schedule. That's a fine trade, and it is a trade, which is not how the spec's SHOULD makes it sound.
This is the fifth surface in the Spring Boot and OAuth2 guide,
after the DCR proxy, the HTTP edge in
Token exchange with Spring Cloud Gateway, and the
messaging path in RabbitMQ over AMQP 0.9.1. The source is
spring-oauth2-mcp-cimd. Clone it, run
mise run demo, and open an issue if something is unclear or broken.
Standards and sources
Plain-English notes on each spec, and how it maps to the code, live in the repo's
docs/STANDARDS.md.
- MCP authorization specification (2025-11-25)
- OAuth Client ID Metadata Document (CIMD), IETF draft
- RFC 7591: OAuth 2.0 Dynamic Client Registration
- RFC 7592: OAuth 2.0 Dynamic Client Registration Management
- RFC 8252: OAuth 2.0 for Native Apps (§7.3 is the any-port loopback rule, §8.3 recommends against
localhost) - RFC 8707: Resource Indicators for OAuth 2.0
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- RFC 9700: Best Current Practice for OAuth 2.0 Security
