OIDC discovery: what /.well-known/openid-configuration actually does

Every OIDC provider publishes a document at a well-known URL that describes its capabilities, endpoints, and supported parameters. This discovery document — defined in OpenID Connect Discovery 1.0 — is what allows OAuth/OIDC client libraries to configure themselves automatically from a single issuer URL. Understanding what's in it, what clients do with it, and what to validate in it saves hours of debugging when integrations break.

Fetching the discovery document

Given an issuer URL like https://accounts.google.com, the discovery document is always at {issuer}/.well-known/openid-configuration. Fetch it with a plain GET:

curl https://accounts.google.com/.well-known/openid-configuration | jq .

The response is a JSON object. A representative subset of the fields you'll see:

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "revocation_endpoint": "https://oauth2.googleapis.com/revoke",
  "introspection_endpoint": "https://oauth2.googleapis.com/tokeninfo",
  "response_types_supported": ["code", "token", "id_token", "code token", "code id_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
  "claims_supported": ["sub", "name", "given_name", "family_name", "email", "email_verified", "picture"],
  "code_challenge_methods_supported": ["plain", "S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}

The issuer field and issuer validation

The issuer field is the most security-critical field in the document. When a client receives an id_token, it must verify that the iss claim in the token exactly matches the issuer value from the discovery document. This prevents token substitution attacks: an attacker who controls IdP A cannot present an id_token from IdP A to your application that's configured to trust IdP B.

class OidcClient {
  private config: OidcDiscoveryDocument | null = null;

  async loadDiscovery(issuerUrl: string): Promise {
    const discoveryUrl = `${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`;
    const res = await fetch(discoveryUrl);
    if (!res.ok) throw new Error(`Discovery fetch failed: ${res.status}`);

    this.config = await res.json();

    // Critical: the issuer in the document must match what you requested
    if (this.config!.issuer !== issuerUrl && this.config!.issuer !== issuerUrl.replace(/\/$/, '')) {
      throw new Error(
        `Issuer mismatch: expected ${issuerUrl}, got ${this.config!.issuer}`
      );
    }
  }

  verifyIdToken(idToken: string, expectedNonce?: string): JwtPayload {
    if (!this.config) throw new Error('Discovery not loaded');

    const payload = verifyJwt(idToken, this.config.jwks_uri, {
      issuer: this.config.issuer,
      audience: process.env.OIDC_CLIENT_ID,
    });

    if (expectedNonce && payload.nonce !== expectedNonce) {
      throw new Error('Nonce mismatch');
    }

    return payload;
  }
}

jwks_uri: where the signing keys live

The jwks_uri points to a JSON Web Key Set — a list of the provider's public keys used to verify id_token signatures. Clients fetch this document and cache the keys. Each key has a kid (key ID) that matches the kid header in the JWT, allowing clients to select the correct key when multiple are published.

Key rotation is why clients must be able to re-fetch the JWKS. The standard pattern: if JWT verification fails due to an unknown kid, re-fetch the JWKS once, then retry. If it still fails, the token is invalid.

import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: 'https://accounts.google.com/.well-known/jwks.json',
  cache: true,
  cacheMaxAge: 10 * 60 * 1000,  // 10 minutes
  rateLimit: true,
  jwksRequestsPerMinute: 10,
});

function getSigningKey(kid: string): Promise {
  return new Promise((resolve, reject) => {
    client.getSigningKey(kid, (err, key) => {
      if (err) return reject(err);
      resolve(key!.getPublicKey());
    });
  });
}

authorization_endpoint and the parameters it accepts

The authorization_endpoint is where your application redirects users to begin the login flow. The discovery document also tells you which parameters the endpoint supports through companion fields:

  • response_types_supported: What values are valid for response_type. For Authorization Code flow, you want code. If code is not in this list, the provider doesn't support the flow.
  • code_challenge_methods_supported: Whether the provider supports PKCE and which methods. Always prefer S256 over plain. If this field is absent, the provider may not support PKCE at all.
  • scopes_supported: Tells you which scopes you can request. If you request a scope not in this list, the provider may reject the request or silently ignore the scope.

token_endpoint_auth_methods_supported

This field controls how your application authenticates to the token endpoint when exchanging an authorization code for tokens. Common values:

  • client_secret_basic: Client credentials in the HTTP Basic Authorization header. The most common.
  • client_secret_post: Client credentials in the POST body. Required by some providers (GitHub, historically).
  • private_key_jwt: Client authenticates with a signed JWT. The most secure option for confidential clients.
  • none: No client authentication — only for public clients (SPAs, mobile apps) using PKCE.

claims_supported and what it means for your user model

The claims_supported field lists the claims that may appear in the id_token or be returned from the userinfo_endpoint. "May appear" is the operative phrase — not all claims are returned for every user, and claims like phone_number_verified require the corresponding scope to be requested.

Use this field when building your user provisioning logic to know which attributes to expect and which to treat as optional:

async function provisionUser(claims: JwtPayload, discoveryDoc: OidcDiscoveryDocument) {
  const supportedClaims = new Set(discoveryDoc.claims_supported ?? []);

  return db.users.upsert({
    sub: claims.sub,                               // always present in OIDC
    email: claims.email,                           // present if 'email' scope requested
    emailVerified: claims.email_verified ?? null,
    name: claims.name ?? null,
    // Only try to use phone if the provider supports it
    phone: supportedClaims.has('phone_number') ? claims.phone_number ?? null : null,
    picture: claims.picture ?? null,
  });
}
Cache the discovery document, but cache it with a reasonable TTL — 1 to 24 hours depending on your requirements. Discovery documents change infrequently, but they do change (endpoint URLs can shift after migrations, new algorithms get added). Treat the document as a live configuration source, not a static artifact.

The discovery document is the machine-readable contract between the OIDC provider and its clients. Reading it carefully before writing integration code prevents an entire class of "why doesn't this work" bugs that come from hardcoding endpoints or assuming algorithm support that isn't actually present.