Most MCP auth tutorials assume your IdP can register clients. Does yours?
The MCP authorization spec turns your server into an OAuth2 resource server and assumes the client self-registers with no human in the loop. That second part is where many enterprise identity providers stop. Microsoft Entra ID has no Dynamic Client Registration endpoint at all, and Spring's own Authorization Server only does a gated OIDC variant, not the open registration the spec leaned on. DCR is the part many enterprise IdPs don't offer.
The cleaner model puts a small Spring Authorization Server in front of the real IdP. The broker exposes DCR to the MCP client, federates the human login upstream to the IdP that can't register clients, then mints its own scoped tokens. The tokens your MCP server trusts come from the broker, not the upstream IdP.
This post builds that broker. The companion repo is
spring-oauth2-mcp, and the whole thing runs
with one command. Here is the one line to hold onto, because everything else is in service of it:
Federated identity in, fine-grained authorization out, and the scopes never come from what the client asks for.
One caveat before the code, because the spec moved while this pattern was being written. The 2025-11-25 revision downgraded DCR from SHOULD to MAY, calling it "included for backwards compatibility with earlier versions of the MCP authorization spec," and put Client ID Metadata Documents (CIMD) above it as the SHOULD-level mechanism for parties with no prior relationship. So the broker isn't a DCR purist. It's the bridge for the messy middle: most enterprise IdPs do neither open DCR nor CIMD, and most shipped clients still try DCR first. First, though, the one mechanism the whole thing turns on.
Dynamic Client Registration in 60 seconds, and why the spec just moved the goalposts
DCR (RFC 7591, July 2015) is one unauthenticated request. A client POSTs its metadata to the
registration endpoint, gets back a client_id, runs auth-code plus PKCE, and calls tools with the
resulting bearer token. No operator provisions anything by hand. In this repo's headless test, that
first step is exactly this:
const res = await fetch(`${AS}/oauth2/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: `e2e-${Date.now()}`,
redirect_uris: [REDIRECT],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none", // public client + PKCE, like the MCP Inspector
}),
})Three RFCs sit under the MCP flow. RFC 7591 is DCR. RFC 9728 (Protected Resource Metadata, April
2025, the newest of the three) is how a 401 tells the client where to find the authorization server.
RFC 8707 (Resource Indicators, February 2020) is the resource parameter that audience-binds the
token to the server the client meant to call.
Now the goalpost move. The 2025-06-18 spec made DCR a SHOULD. The 2025-11-25 revision (current as I
write this in mid-2026) rewrote it: DCR is now a MAY, kept for backwards compatibility, and CIMD is
the SHOULD-level mechanism listed above it. RFC 9728 stays a MUST. The RFC 8707 resource parameter
stays a MUST. So the discovery and audience-binding parts didn't soften. Registration did.
Worth flagging, because it cuts against the pattern: CIMD rests on an IETF draft
(draft-ietf-oauth-client-id-metadata-document, at -01 as of mid-2026), not yet an RFC. And in
mid-2026, clients like Claude Code, Cursor, and the MCP Inspector still attempt DCR first. That's
the design decision behind this broker. It's a DCR broker today, with a deliberate CIMD seam. It
implements DCR because that's what shipped clients send, and the code is structured so CIMD can be
slotted in later: advertise client_id_metadata_document_supported, then fetch and validate the
client-hosted metadata document. None of that is wired yet. And CIMD isn't free when that day comes.
The moment the broker fetches a client-supplied client_id URL, it inherits the spec's own
warnings: an authorization server reaching an attacker-chosen URL is an SSRF vector against internal
endpoints, and pinning trust to localhost redirect URIs invites impersonation. The seam would need
SSRF egress controls and a redirect-URI trust policy before it became a code path.
What we're building: two services, a real IdP, and a one-command demo
Three moving parts, two of them Spring. The authorization server (:9000) is the broker. The
mcp-server (:9200) is a Spring AI 2.0 MCP server exposing four toy tools: a whoami identity
probe plus three operations over an in-memory notes store. Keycloak (:80 through Traefik) is the
upstream IdP, here playing a locked-down enterprise IdP like Entra ID that has no open registration
endpoint. Keycloak can actually do DCR itself; the demo doesn't lean on that, because the point is
for the broker to own registration.
Two users carry the whole demo. alice has the realm role user, which maps to note:read and
note:write. bob has the realm role admin, which adds note:admin. The allow/deny matrix
across those two is the proof.
The four tools are the scope surface, one tool per scope on purpose:
| Tool | Required scope | Who can call it |
|---|---|---|
whoami | any authenticated caller | alice, bob |
list_notes | note:read | alice, bob |
create_note | note:write | alice, bob |
purge_notes | note:admin (destructive) | bob only |
One command runs it:
mise run demoThat builds both modules, starts Traefik, Postgres, and Keycloak, provisions the demo realm,
starts both services, runs the discovery checks, then performs the allowed and denied tool calls for
alice and bob live.
A word on versions, because pre-1.0 honesty matters here. The stack is Spring Boot 4.1.0, Spring AI
2.0.0, Java 25, BouncyCastle 1.84, Keycloak 26.6, Traefik 3.7, Postgres 18. The MCP security
artifacts from org.springaicommunity are 0.1.13, which is pre-1.0. Spring Security 7.1.0, the
MCP SDK 2.0.0, and Spring Authorization Server come in transitively managed by Boot 4.1.0, not
pinned in the pom. This is a reference to learn from, not something I'd point at production, and
there's a security notice in the repo that says so.
The broker: a Spring Authorization Server doing three jobs at once
The broker is three things at once, and naming all three up front makes the rest read cleanly. It's an OAuth2 authorization server (it issues tokens to MCP clients). It's an OAuth2 client (it federates the human login to Keycloak). And it's a DCR plus scope layer (the part the broker owns instead of the upstream IdP).
The load-bearing line is one attribute. The authorization-server chain enables
.dynamicClientRegistration(true) through the mcpAuthorizationServer() configurer. That one
attribute turns the register endpoint on:
.with(
mcpAuthorizationServer(), mcp -> mcp
.dynamicClientRegistration(true)
.authorizationServer(authzServer -> {
// Advertise ES256 in the provider metadata (Spring defaults to RS256).
authzServer.oidc(oidc -> oidc.providerConfigurationEndpoint(endpoint ->
endpoint.providerConfigurationCustomizer(cfg ->
cfg.idTokenSigningAlgorithms(algs -> {
algs.clear();
algs.add(SignatureAlgorithm.ES256.getName());
}))));
http.securityMatcher(authzServer.getEndpointsMatcher());
})
)There are three security chains: actuator, authorization-server, and login. The login chain is the
federation seam. It runs oauth2Login against the upstream registration id, which is where the
human actually authenticates against Keycloak:
.oauth2Login(login -> login.loginPage(upstream.authorizationRequestUri()))Who authenticates is a config choice, not a rewrite. The upstream registration id lives in
app.upstream.registration-id, bound by a tiny UpstreamProperties record, so the upstream IdP
stays a property rather than a hardcoded "keycloak". The broker stays the DCR plus scope layer
either way. That's the whole DCR-proxy thesis in one sentence: the upstream IdP doesn't expose open
DCR to your MCP clients, so the Spring AS sits in front, adds DCR, and delegates the human
authentication upstream.
The registered DCR clients, their authorizations, and consents persist to Postgres through JDBC stores (Liquibase manages the tables). I'll come back to watching DCR rows pile up in the proof section. The federation gets you a logged-in human. Now the interesting part: turning that human into scopes.
Roles in, scopes out, and the scope claim never comes from the client
Start with the threat. If you let the client's requested scopes flow onto the minted token, a
roleless caller mints note:read just by asking for it. The fix is to derive scopes from the
caller's upstream roles and ignore what the client requested.
RoleScopeMapper.scopesFor() is the contract. It expands realm roles into a fixed scope set: the
user role grants note:read and note:write, the admin role adds note:admin. The scopes are an
API contract, declared static final. Only the role names are configurable, bound from
app.security.roles.*:
public static final Set<String> USER_SCOPES = Set.of("note:read", "note:write");
public static final Set<String> ADMIN_SCOPES = Set.of("note:admin");
public Set<String> scopesFor(Set<String> roles) {
Set<String> scopes = new TreeSet<>();
if (roles.contains(userRole) || roles.contains(adminRole)) {
scopes.addAll(USER_SCOPES);
}
if (roles.contains(adminRole)) {
scopes.addAll(ADMIN_SCOPES);
}
return scopes;
}The fail-closed line is the one that matters. The token customizer stamps an empty scope claim when no mappable role is present, with the comment explaining exactly why:
final Set<String> granted = roleScopeMapper.scopesFor(roles);
if (granted.isEmpty()) {
// FAIL CLOSED: if we left the claim alone, Spring Authorization Server would stamp the
// client's REQUESTED scopes: a roleless principal could mint a note:read token just by
// asking (DCR registers requested scopes verbatim). No mappable roles ⇒ no scopes.
context.getClaims().claim(OAuth2ParameterNames.SCOPE, Set.of());
return;
}
// Scope derives from the upstream roles, never the client request. Stamp it directly.
context.getClaims().claim(OAuth2ParameterNames.SCOPE, granted);Below the headline, the other branch is just as deliberate. When roles do map, the role-derived set
is stamped onto the scope claim directly, and the customizer never reads what the client
requested. Roles are the only input, so the role set is the hard ceiling, and no requested scope can
widen it.
Now the failure I want you to see before the fix, because it's the kind that denies everything
silently. Keycloak's default realm-roles mapper writes the nested realm_access.roles to the
access token. But the broker reads the ID token, because it's acting as an OIDC client. Without
a custom realm-roles to flat-roles ID-token mapper, the broker sees no roles, maps an empty scope
set, and (failing closed) denies every tool for both users. The Keycloak protocol mapper is the
contract between the IdP and ScopeMappingConfiguration:
// Keycloak's default mapper only writes the nested realm_access.roles to the ACCESS token, but the
// authorization server is an OIDC client reading the ID token, so without this it sees no roles and
// (failing closed) denies every tool for both alice and bob.
config: {
"claim.name": "roles",
"id.token.claim": "true",
"access.token.claim": "true",
// ...plus jsonType.label, multivalued, and the userinfo/introspection flags
}The same family of token customizers carries the human identity onto the minted token.
AccessTokenClaimsConfiguration.copyIdentity() copies preferred_username, email, and name off
the upstream OidcUser, so whoami and a note's author show the real person, not a subject UUID:
claim(context, "preferred_username", user.getPreferredUsername());
claim(context, "email", user.getEmail());
claim(context, "name", user.getFullName());That carries the user's identity the way the gateway in Token exchange with Spring Cloud Gateway carries it downstream, though this broker derives scopes from roles rather than running an RFC 8693 exchange. The claims are right. Now the token has to be signed in a way the resource server will accept, and that's where I lost an afternoon.
ES256 takes three coordinated pieces, or every token is silently rejected
Spring Authorization Server defaults to RS256. Switching to ES256 is not one switch. It's three pieces across two config classes, and dropping any one mints RS256 with no matching key, so every downstream token is rejected. That was the afternoon.
ES256 is the better greenfield default anyway. A P-256 signature is 64 bytes against RSA-2048's 256, at a security level matching RSA-3072, and it's what WebAuthn, COSE, and newer identity standards already lean on. RS256 isn't broken, it's just heavier.
Here are the three pieces. Piece one is the EC P-256 key in the JWKS (a committed dev PEM parsed via
BouncyCastle, or an ephemeral key if you don't supply one). Piece two forces the per-token JWS
header to ES256, because JwtGenerator defaults to RS256. Both live in JwksConfiguration, and the
class javadoc names all three:
/** Piece 1: a single EC P-256 key in the JWKS (served at /oauth2/jwks for the resource server). */
@Bean
JWKSource<SecurityContext> jwkSource(JwksProperties properties) throws Exception {
final var pem = properties.signingKeyPem();
ECKey ecKey = pem.isBlank() ? generateEphemeralKey() : loadFromPem(pem);
return new ImmutableJWKSet<>(new JWKSet(ecKey));
}
/** Piece 2: force the JWS header to ES256. Without it JwtGenerator defaults to RS256 (no key). */
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> es256JwsHeaderCustomizer() {
return context -> context.getJwsHeader().algorithm(SignatureAlgorithm.ES256);
}Piece three is advertising ES256 in the OIDC provider metadata, by clearing the default
idTokenSigningAlgorithms and setting ES256. That one lives in WebSecurityConfiguration (you saw
it in the DCR snippet above). Two files, one outcome. That split is exactly why it's easy to get two
of three right and ship a server that mints tokens nobody accepts.
The symptom is the nasty part. There's no error at mint time. The token fails validation at the resource server, silently, with a signature or algorithm mismatch. The bug isn't where the failure shows up. If you've ever needed a local-JWKS-validatable JWT for another resource server, this is the same plumbing I walked through for the broker in OAuth2-secured RabbitMQ over AMQP 0.9.1, where the broker pulls the issuer's JWKS and checks the signature itself.
A correctly signed, correctly scoped token is still only half the contract. The resource server has to refuse the ones that aren't for it.
The MCP server: a clean resource server that gates every tool
The mcp-server stays simple on purpose. It's a Spring AI 2.0.0 MCP server over Streamable HTTP on
WebMVC at /mcp, secured as an OAuth2 resource server through org.springaicommunity's
mcp-server-security.
Discovery is a handshake. A 401 carries a WWW-Authenticate header pointing at
/.well-known/oauth-protected-resource/mcp (RFC 9728), which points the client at the broker. From
there the client runs DCR and the auth-code flow.
Token validation runs all three checks the architecture promises: signature via JWKS, issuer, and
audience. All three are genuinely wired, so I'll say so plainly. mcpServerOAuth2() resolves the
issuer's JWKS eagerly at boot through OIDC discovery, checks the issuer, and validates the audience.
The audience check is config-driven and on in this demo:
.with(
mcpServerOAuth2(),
mcp -> mcp.authorizationServer(properties.issuerUri())
.validateAudienceClaim(properties.validateAudience())
)That audience is the RFC 8707 link. The client sends a resource indicator on /authorize, the
broker stamps it into aud, and the resource server rejects any token whose aud is for something
else. That closes the confused-deputy hole on the resource side. The MCP spec makes this audience
check a MUST, so treat validateAudienceClaim as on-always: the flag exists only to accept clients
that omit resource, and turning it off reopens the hole. Two caveats even with it on. The demo
stamps whatever resource the client asks for, so a production broker should require it and reject
an unrecognized one with invalid_target. And bindAudience only sets aud when the client sends
a resource, so a missing one falls back to Spring's default of the client id. The other half of
confused-deputy, per-client consent, is the first item in what I'd change before shipping.
Now the worst kind of bug, the silent no-op. @EnableMethodSecurity is load-bearing. Without it,
every @PreAuthorize gate silently does nothing and any authenticated caller can call every tool.
It fails open, and it fails without a sound:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties(McpServerSecurityProperties.class)
class McpServerSecurityConfiguration {With method security on, the gates do their job. Each @McpTool carries a @PreAuthorize for the
scope it needs; whoami only needs isAuthenticated():
@McpTool(name = "list_notes", description = "List all notes. Requires the note:read scope.")
@PreAuthorize("hasAuthority('SCOPE_note:read')")
public List<Note> listNotes() {
return List.copyOf(notes);
}
@McpTool(name = "purge_notes", description = "Delete ALL notes. Admin-only, requires the note:admin scope.")
@PreAuthorize("hasAuthority('SCOPE_note:admin')")
public String purgeNotes() { /* ... */ }When a scope is missing, the AccessDeniedException comes back as an MCP tool error
(isError=true, HTTP 200). A missing or invalid token is rejected earlier with a 401. Beyond that
the surface stays minimal: stateless sessions, so there's no cookie and no CSRF to defend, plus a
Jackson Throwable mix-in that strips stackTrace and cause so a transport error never leaks the
server's stack on the wire.
One MCP-specific gotcha worth a sentence: keep tool bodies synchronous. No @Async, no executor, no
streaming, or the SecurityContext (and with it @PreAuthorize) is lost.
So far I've claimed two things: the gates work, and identity propagates. The only way to trust either is to force them to fail.
Prove it: the failure I forced, and the identity I asserted
The production happy path avoids these failures, so the only way to prove the avoidance is real is to force them, on two layers: offline and live.
Offline, with no web server or JWKS in play, method-security slice tests (DebugToolSecurityTest,
NoteToolSecurityTest) would fail under the silent @EnableMethodSecurity bypass, and
RoleScopeMapperTest proves an unknown role maps to an empty set. Run them with:
mise run testLive and headless, test/e2e.ts drives the whole chain per user with nothing but Node's fetch, no
browser: DCR, then auth-code plus PKCE with a resource indicator, then the federated Keycloak
login and consent, then an ES256 token, then Streamable-HTTP tool calls. Run it with:
mise run test:e2eThe output shape is the proof. Per user, it prints the DCR-issued client_id, the role-derived
token scopes, the allow/deny matrix, the propagated identity, and a PASS line:
DCR → authorization server issued client_id: ...
=== alice (user) ===
token scopes: [note:read, note:write]
✓ ALLOW whoami
✓ ALLOW list_notes
✓ ALLOW create_note
✗ DENY purge_notes (Access Denied)
identity: username=alice email=alice@example.com note.author=alice
PASS: alice (user)
...
ALL E2E CHECKS PASSED ✅Here's the part most demos skip: the e2e doesn't just assert allow and deny, it asserts that the
human identity propagated end to end. whoami.username, whoami.email (alice@example.com,
bob@example.com), and create_note.author must equal the real user, not the subject UUID. It's
the regression guard for identity-claim propagation:
const idChecks: Array<[string, unknown, unknown]> = [
["whoami.username", who?.username, user],
["whoami.email", who?.email, `${user}@example.com`],
["create_note.author", note?.author, user],
]And you can watch DCR happen for real. Every registration is a row in Postgres:
SELECT client_id, client_name, scopes FROM oauth2_registered_client;Run the e2e a few times and the rows pile up. Which is a fine segue, because those accumulating rows are also the first thing I'd change before shipping this.
Swapping the IdP: config for the names, code for the claim shape
The design exists so the upstream IdP is a swap, not a rewrite. Let me be precise about which is which.
There are two swap seams. First, the role names are config (app.security.roles.*). Second, the
scopes are a fixed static final API contract, so those don't move. But there's a third thing that
isn't config: if a new IdP delivers its roles under a different claim shape than the flat roles
claim this demo reads, the claim reader in ScopeMappingConfiguration needs a small code change.
Config for the role names, a code change for the claim shape.
Now the blast radius. For Keycloak, swapping is a property change. For Entra ID it's the property
change, plus a claim-reader tweak (its roles and groups have their own shape), plus accepting
that Entra has no DCR endpoint at all. That last one is the whole reason the proxy exists, and it
bites harder there, not less.
The honest spec verdict is the cleanest argument for the pattern. Even Spring's own Authorization
Server doesn't give MCP the anonymous DCR the pre-November-2025 spec assumed. It implements OIDC DCR
(not full open RFC 7591), disabled by default, at /connect/register, requiring a single-use
initial access token that carries the client.create scope. That friction is exactly what an
autonomous MCP client can't satisfy. So the broker pattern stands whether your upstream is Entra,
Okta, or a locked-down Spring AS.
This is a reference to learn from, not something you'd deploy. Here's the gap between the two.
What I'd change before shipping this
This is a demo optimized for clarity. Here's where it breaks, in rough order of how much it would keep me up at night.
Per-client consent is the gap that should bother you most, and the MCP 2025-11-25 spec makes it a
MUST. The broker federates every login through a single static upstream client (named keycloak in
the demo) to Keycloak, so the consent screen is bound to that one client, not to the MCP client that
registered through DCR. Once a user has a live Keycloak session and has consented to it, an
attacker-registered DCR client can ride that still-valid upstream consent with no fresh prompt.
That's the confused-deputy problem the spec names: a proxy with a static upstream client id MUST get
the user's consent for each dynamically registered client before forwarding it upstream. PKCE and
redirect-URI matching don't help, because the flow is mechanically valid; the missing step is a
per-client yes. The fix is to re-enable the broker's own consent page for DCR clients, so each
registered client earns its own explicit grant. One gotcha if you do: the broker serves only JSON
and redirects today, so its content-security policy is a strict default-src 'self' with no inline
allowance. A consent page is HTML, so turning it back on means relaxing that policy for inline
script and style, or shipping a consent template that uses neither.
Open DCR is the next risk. Anyone can POST to the register endpoint unauthenticated and stand up a
client with arbitrary self-asserted metadata, including a redirect_uri they control (RFC 7591 §5).
PKCE blocks code interception and exact redirect-URI matching blocks open-redirect abuse of an
honest client, but neither touches what open DCR actually adds: an unauthenticated party registering
a client at all. A public deployment needs an RFC 7591 initial-access-token (or gateway auth) gate
on the register endpoint, a host allowlist for the registered redirect_uri, rate-limiting, and
stale-client pruning. The rows piling up in Postgres are the visible symptom, not the real risk.
Committed secrets. The ES256 PEM and the keycloak-dev-secret client secret are plaintext in
application.yml and the Keycloak config. Use a real key and a secrets manager.
Keycloak runs start-dev with SSL disabled and the master realm's sslRequired relaxed over HTTP.
The upstream keycloak client uses fullScopeAllowed=true, so every realm role flows in (scope it
down). consentRequired=true is on so the user sees Keycloak's consent screen, and the test
passwords are trivial.
The refresh-token policy is stamped on every client, including DCR ones. OAuth2StoreConfiguration
adds REFRESH_TOKEN only for the authorization_code grant, not client_credentials (RFC 6749
§4.4). These are public, secretless clients (token_endpoint_auth_method: none), and RFC 9700
§2.2.2 requires their refresh tokens to be sender-constrained or rotated, so
reuseRefreshTokens(false) is the floor, not a nicety. Access lives 30 minutes, refresh 7 days:
// Demo constants (the broker no longer binds these from yaml). Externalize to tune per environment.
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(7);
final var tokenSettings = TokenSettings.builder()
.accessTokenTimeToLive(ACCESS_TOKEN_TTL)
.refreshTokenTimeToLive(REFRESH_TOKEN_TTL)
.reuseRefreshTokens(false)
.build();
// REFRESH_TOKEN only for authorization_code, not for client_credentials (RFC 6749 §4.4).And there's the plumbing war story, the same one that cost me a separate afternoon on the RabbitMQ
post. With no HTTP client library on the classpath, Spring's RestClient falls back to the JDK
HttpClient, which attempts an h2c (cleartext HTTP/2) upgrade on its first request. Against
plain-HTTP Keycloak-behind-Traefik, that upgrade hangs, and the whole federated token exchange
stalls with no error to read. Adding Apache HttpComponents 5 forces HTTP/1.1 and fixes it:
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>One dependency, no code. I spent more time on this than I want to admit, and the symptom points nowhere near the cause.
The broker is the bridge for the messy middle
The MCP spec wants self-registering clients. Your enterprise IdP won't oblige. A small Spring Authorization Server in front of it closes the gap: federated identity in, role-derived scopes out, ES256 tokens audience-bound to the resource that asked. The scopes never come from what the client requested, which is the one property that keeps an autonomous client from granting itself anything.
Where the spec stands today, DCR is the backwards-compat MAY path, CIMD is the SHOULD-level mechanism for parties with no prior relationship, and most enterprise IdPs do neither. The broker is the pragmatic bridge until the installed base catches up: DCR today, with the CIMD seam left open rather than a DCR-only dead end. The spec will move again. The broker is the seam that lets you move with it.
This post is the fourth surface in the
Spring Boot and OAuth2 guide, after the HTTP edge in
Token exchange with Spring Cloud Gateway and the
messaging path in RabbitMQ over AMQP 0.9.1 and its
AMQP 1.0 follow-up. The full source is
spring-oauth2-mcp. Clone it, run
mise run demo, and open an issue if something is unclear or broken.
Standards and sources
Every spec claim above links to its primary source, collected here. Plain-English notes on each, and
how they map 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 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
- RFC 6749: The OAuth 2.0 Authorization Framework