Discovery, Metadata and Client Registration
|
This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against. OAuth 2.1 is still an Internet-Draft ( This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Every protocol page so far has assumed a client already knows the authorization server’s endpoint URLs,
supported algorithms and public keys. In practice a client should never hardcode any of that: discovery
documents let it fetch this configuration at start-up (or on a cache-refresh schedule) from one well-known URL,
and dynamic registration lets it obtain a client_id the same way instead of a human filling in an admin
console. This page covers both, plus the companion metadata document a resource server publishes so a client
knows which authorization server(s) it trusts.
OpenID Connect Discovery and RFC 8414 authorization server metadata
An OIDC issuer publishes its configuration at <issuer>/.well-known/openid-configuration; a plain OAuth 2.0
authorization server (no OIDC) publishes the RFC 8414 equivalent at
<issuer>/.well-known/oauth-authorization-server. Both are a single unauthenticated GET:
GET /.well-known/openid-configuration HTTP/1.1
Host: auth.example.com
Accept: application/json
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth2/authorize",
"token_endpoint": "https://auth.example.com/oauth2/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/oauth2/jwks",
"registration_endpoint": "https://auth.example.com/connect/register",
"revocation_endpoint": "https://auth.example.com/oauth2/revoke",
"introspection_endpoint": "https://auth.example.com/oauth2/introspect",
"end_session_endpoint": "https://auth.example.com/connect/logout",
"pushed_authorization_request_endpoint": "https://auth.example.com/oauth2/par",
"require_pushed_authorization_requests": false,
"scopes_supported": ["openid", "profile", "email", "address", "phone", "offline_access"],
"response_types_supported": ["code", "code id_token"],
"response_modes_supported": ["query", "fragment", "form_post"],
"grant_types_supported": [
"authorization_code", "refresh_token", "client_credentials",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange"
],
"subject_types_supported": ["public", "pairwise"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth", "none"
],
"token_endpoint_auth_signing_alg_values_supported": ["RS256", "ES256"],
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "acr", "amr", "email", "email_verified", "name"],
"code_challenge_methods_supported": ["S256"],
"dpop_signing_alg_values_supported": ["RS256", "ES256"],
"request_parameter_supported": true,
"request_uri_parameter_supported": true
}
The fields a client actually reads and acts on:
| Field | What a client does with it |
|---|---|
|
The exact string every |
|
Where the client sends the browser to start |
|
Where the client sends the code (or refresh token, client credentials, etc.) to get tokens |
|
Present for OIDC issuers; see OpenID Connect |
|
Where to fetch the public keys used to verify ID tokens and JWT access tokens |
|
RP-Initiated Logout target; see Logout and Session Management |
|
|
|
|
|
Where a client performs dynamic registration (below) |
|
What the client may legally request; requesting an unlisted scope should be expected to fail or be silently dropped |
|
Which |
|
Which grants are enabled — a client should not assume |
|
Which client-authentication methods this AS accepts; see Client Credentials and Client Authentication |
|
|
|
The algorithms a client’s JWT/DPoP-proof validation and generation code must be prepared to handle |
|
Must contain |
|
Discovery documents are meant to be cached, not fetched on every request — typically once at start-up and then
on a TTL (respecting any |
Trust, not just convenience
Discovery removes hardcoded URLs, but it does not remove the need to know which issuer to trust in the first
place: fetching https://auth.example.com/.well-known/openid-configuration over TLS proves the document came
from whoever controls that hostname, not that the hostname itself is one this client should trust. A client
should keep an explicit allow-list of acceptable issuer values (or issuer patterns, for the multi-tenant case
below) rather than treating "discovery succeeded" as equivalent to "this authorization server is authorized" — otherwise a client that blindly follows a resource_metadata hint (below) or an issuer value taken from
user-controlled input could be walked into fetching configuration from, and then trusting tokens issued by, an
attacker-controlled authorization server.
RFC 9728: protected resource metadata
Discovery above describes an authorization server. RFC 9728 does the symmetric thing for a resource server: it publishes its own metadata document so a client (or, in agentic/MCP-style deployments, a fully automated client with no human operator to consult documentation) can discover which authorization server(s) issue tokens it accepts, without that being baked into the client at build time.
GET /.well-known/oauth-protected-resource HTTP/1.1
Host: api.example.com
Accept: application/json
{
"resource": "https://api.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["orders:read", "orders:write"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://api.example.com/docs"
}
The other half of RFC 9728 is how a client discovers this document without already knowing the resource’s
.well-known path: a resource server that rejects a request for missing or invalid credentials returns the
document’s own URL in the WWW-Authenticate challenge, via the resource_metadata parameter:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",
error="invalid_token",
error_description="The access token expired"
A client (or, again, an autonomous agent) that receives this challenge can follow resource_metadata, read
authorization_servers, run ordinary discovery against whichever one it trusts, and obtain a token for
resource — all without a human ever having configured an issuer URL for this particular API by hand. This is
the mechanism Scopes, Claims and Permissions assumes when
it discusses resource indicators (RFC 8707) as the audience-restriction answer.
Resource-first discovery, end to end
Putting RFC 9728 and RFC 8414/OIDC Discovery together gives a client (or an autonomous agent with no
hand-configured issuer) a complete, unattended path from "here is an API" to "here is a valid access token for
it": call the resource server without credentials, follow resource_metadata from the WWW-Authenticate
challenge, read authorization_servers from the returned protected-resource document, run ordinary discovery
against the (allow-listed) issuer it names, and proceed with whichever grant fits the client, using the
token_endpoint and scopes_supported that discovery just supplied:
Dynamic client registration and management
RFC 7591 lets a client obtain a client_id (and, for confidential clients, a client_secret) by sending its
metadata to the registration_endpoint, instead of a human registering it through an admin console:
POST /connect/register HTTP/1.1
Host: auth.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.initial-access-token-payload.signature
{
"client_name": "Example Mobile App",
"redirect_uris": ["com.example.app:/oauth2redirect"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "native",
"software_statement": "eyJhbGciOiJSUzI1NiJ9.eyJzb2Z0d2FyZV9pZCI6ImV4YW1wbGUtbW9iaWxlIn0.signature"
}
The Authorization: Bearer header here carries an initial access token — a credential, provisioned
out-of-band by the AS operator, that authorizes registering a client without yet identifying which one; many
deployments allow open (unauthenticated) registration instead, which is only appropriate when every registered
client is treated as fully untrusted until a human reviews it. software_statement is an optional signed JWT,
issued by a trusted third party (a software publisher or a certification authority), asserting facts about the
client software itself (its software_id, publisher identity, requested redirect URIs) so the AS can decide
whether to trust a self-asserted registration without a human in the loop.
The response echoes the registered metadata back and adds the credential plus a management handle:
{
"client_id": "s6BhdRkqt3",
"client_id_issued_at": 1757865200,
"client_name": "Example Mobile App",
"redirect_uris": ["com.example.app:/oauth2redirect"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"registration_access_token": "reg-23410913-abewfq.123483",
"registration_client_uri": "https://auth.example.com/connect/register/s6BhdRkqt3"
}
RFC 7592 defines what the client can do next with registration_access_token and registration_client_uri:
GET to read the current registration back, PUT to update it (redirect URIs, scopes, contacts), and DELETE
to deregister. This bearer token, not the client’s own OAuth credentials, is what authorizes these management
calls — store it as carefully as a client secret.
|
Open dynamic registration is an attack surface: anything that can |
The client-ID metadata document draft
draft-ietf-oauth-client-id-metadata-document is an in-progress alternative that skips registration entirely:
the client_id itself is an https:// URL, and the authorization server dereferences it to fetch the
client’s metadata document (redirect URIs, name, logo) on demand, the same way it would fetch a jwks_uri. This
avoids a stateful registration step and its associated secret-provisioning problem for public clients, at the
cost of requiring the client to host that document somewhere stable. As an Internet-Draft, its exact metadata
shape and validation rules should be checked against the current revision before relying on it.
Issuer identification and multi-tenant issuers
iss in a token, and the issuer field in discovery, must match byte-for-byte or a client/resource server must
reject the token — this is the core defence RFC 9207 formalises against mix-up attacks, where a client is
tricked into sending a code or token to the wrong authorization server (see
Security Best Practices for the full attack and mitigation).
Multi-tenant deployments (one authorization server product, many independent customer tenants) usually encode
the tenant in the issuer URL itself, so each tenant gets its own discovery document, its own signing keys, and
its own iss value:
| Pattern | Example |
|---|---|
Path-segment tenant |
|
Subdomain tenant |
|
Shared issuer, tenant claim |
One |
A client integrating with a multi-tenant AS should treat the issuer, not just the client ID, as tenant-scoped
configuration, and validate iss against the specific tenant it registered with — never against a wildcard or
a "starts with" match on the platform’s base domain.
JWKS publication and key rollover
jwks_uri (or jwk-set-uri on the Spring side) points to a JSON Web Key Set: the public half of every signing
key the authorization server currently uses, or has very recently retired, each tagged with a kid:
{
"keys": [
{"kty": "RSA", "use": "sig", "kid": "2026-09-01", "alg": "RS256", "n": "...", "e": "AQAB"},
{"kty": "RSA", "use": "sig", "kid": "2026-06-01", "alg": "RS256", "n": "...", "e": "AQAB"}
]
}
Key rollover works because both sides cooperate around kid:
-
The authorization server introduces a new key, starts advertising it in
jwks_uribefore it starts signing with it, keeps the old key in the set for as long as the longest-lived token signed with it can still be outstanding, and only then removes the old entry. -
The resource server (or client, for ID tokens) caches the key set, keyed by
kid, and refetches it on a cache miss — i.e. when it sees akidin a token’s header that is not in its local cache — rather than on every request. A resource server that fails to refetch on a cache miss will start rejecting perfectly valid tokens the moment the AS rotates; one that refetches on every unrecognisedkidwith no rate limit hands an attacker a way to force excessivejwks_uritraffic by sending tokens with garbagekidvalues, so a short negative-cache / rate limit on failed lookups is the usual middle ground.
See JWT and JOSE for kid-based key selection as part of the full JWS
validation pipeline, including the kid path-traversal class of bug that comes from trusting the header instead
of a locally cached key set. On the Spring side, NimbusJwtDecoder.withJwkSetUri(…) handles exactly this
caching and refetch-on-miss behaviour, and the full Spring Authorization Server protocol endpoint list — including its own /oauth2/jwks — is documented in
Authorization Server & Social Login rather
than repeated here.