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 (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

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

issuer

The exact string every iss claim in tokens from this AS must equal; also the base the client used to build this URL, so it must match verbatim (RFC 9207 relies on this equality check to defeat mix-up attacks — see Security Best Practices)

authorization_endpoint

Where the client sends the browser to start authorization_code

token_endpoint

Where the client sends the code (or refresh token, client credentials, etc.) to get tokens

userinfo_endpoint

Present for OIDC issuers; see OpenID Connect

jwks_uri

Where to fetch the public keys used to verify ID tokens and JWT access tokens

end_session_endpoint

RP-Initiated Logout target; see Logout and Session Management

revocation_endpoint / introspection_endpoint

See Opaque Tokens, Introspection and Revocation

pushed_authorization_request_endpoint / require_pushed_authorization_requests

See PAR, JAR and Hardened Profiles

registration_endpoint

Where a client performs dynamic registration (below)

scopes_supported, claims_supported

What the client may legally request; requesting an unlisted scope should be expected to fail or be silently dropped

response_types_supported, response_modes_supported

Which response_type / response_mode combinations (including form_post, see Social Login and Federation for why Apple requires it) this AS accepts

grant_types_supported

Which grants are enabled — a client should not assume client_credentials or the device-code grant is available without checking

token_endpoint_auth_methods_supported

Which client-authentication methods this AS accepts; see Client Credentials and Client Authentication

subject_types_supported

public and/or pairwise — see OpenID Connect

id_token_signing_alg_values_supported, dpop_signing_alg_values_supported

The algorithms a client’s JWT/DPoP-proof validation and generation code must be prepared to handle

code_challenge_methods_supported

Must contain S256 for PKCE-capable clients; the presence of plain is a red flag, not a feature to use

Discovery documents are meant to be cached, not fetched on every request — typically once at start-up and then on a TTL (respecting any Cache-Control header) or on a signature-validation failure that might indicate key rollover. Fetching it synchronously in a request’s hot path defeats the point and adds an availability dependency on the AS for every single call.

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:

sequenceDiagram participant C as Client participant RS as Resource server participant AS as Authorization server C->>RS: GET /api/orders (no credentials) RS->>C: 401, WWW-Authenticate: resource_metadata=".../.well-known/oauth-protected-resource" C->>RS: GET /.well-known/oauth-protected-resource RS->>C: resource, authorization_servers, scopes_supported C->>AS: GET /.well-known/openid-configuration AS->>C: token_endpoint, jwks_uri, grant_types_supported C->>AS: POST /oauth2/token (grant appropriate to this client) AS->>C: access_token C->>RS: GET /api/orders (Authorization: Bearer access_token) RS->>C: 200 resource

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 POST to registration_endpoint without an initial access token can mint a new client_id. Gate it behind an initial access token or a software_statement requirement in any deployment where an attacker registering an arbitrary malicious "client" would be a problem — which is most of them.

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

https://auth.example.com/t/acme-corp (discovery at …​/t/acme-corp/.well-known/openid-configuration)

Subdomain tenant

https://acme-corp.auth.example.com

Shared issuer, tenant claim

One iss for the whole platform, with a tid (tenant ID) claim inside the token instead — riskier, because a client that forgets to check tid accepts tokens from any tenant on the platform

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_uri before 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 a kid in 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 unrecognised kid with no rate limit hands an attacker a way to force excessive jwks_uri traffic by sending tokens with garbage kid values, 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.