Authorization Server & Social Login

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page has two halves that often ship together. The first builds an authorization server — your own OAuth2 / OpenID Connect issuer — with Spring Authorization Server, so your applications get login, logout, and access / refresh / ID tokens from an endpoint you control. The second adds social login — "Sign in with Google / Microsoft / Apple" — with oauth2Login(), where your app is the client of someone else’s issuer and you reconcile the external identity against a local account. It builds on Spring Security: the filter chain, method security, and the SecurityContext all work exactly as described there. The REST endpoints below follow REST APIs, the YAML is bound per-environment as in Configuration & Profiles, and flows are exercised in tests with spring-security-test and MockMvcTester / WebTestClient — see Unit & Integration Testing.

For the protocol underneath all of this — which grant type to expose and why (Choosing an OAuth Flow), and what each authorization- and token-request parameter does, including why PKCE is now required even for confidential clients (Authorization Code and PKCE) — see the OAuth Reference.

When to build your own

An authorization server issues tokens for your client applications and APIs. Build one with Spring Authorization Server when you own the user identities, need full control over token contents, consent, and client registration, and want no external dependency in the login path. If you would be happy delegating all of that, run Keycloak or a managed IdP (Auth0, Okta, Microsoft Entra ID, AWS Cognito) instead and make your services plain resource servers and oauth2Login clients against it — far less to operate.

Spring Authorization Server has moved into Spring Security 7.0. Depend on org.springframework.security:spring-security-oauth2-authorization-server, or the Boot starter spring-boot-starter-oauth2-authorization-server. Two API changes matter when following older material: the OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) helper is gone — use OAuth2AuthorizationServerConfigurer.authorizationServer() — and PKCE is required by default for authorization_code, including for confidential clients unless you opt out per client.

Configuring the authorization server

Two SecurityFilterChain beans, ordered. The first matches only the protocol endpoints; the second is the app’s own chain that actually authenticates the end user.

@Configuration
public class AuthorizationServerConfig {

    @Bean
    @Order(1)
    SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServer =
                OAuth2AuthorizationServerConfigurer.authorizationServer();

        http
            .securityMatcher(authorizationServer.getEndpointsMatcher())
            .with(authorizationServer, server -> server
                .oidc(Customizer.withDefaults()))          // enable the OIDC endpoints
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .exceptionHandling(ex -> ex
                .defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));   // for /userinfo, /connect/register
        return http.build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain appChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());         // and/or .oauth2Login(...) for social login
        return http.build();
    }
}

The end user is authenticated by a UserDetailsService — reuse the DB-backed one from Spring Security ("Building the SecurityContext from credentials"). formLogin (and /logout) belong to the @Order(2) chain; every /oauth2/ and /connect/ endpoint below belongs to the @Order(1) chain, and a browser hitting /oauth2/authorize while unauthenticated is redirected to /login by the entry point above.

Default endpoints

Endpoint Purpose

/oauth2/authorize

Authorization endpoint — starts authorization_code; issues the code after login + consent

/oauth2/token

Token endpoint — exchanges a code (or refresh token) for access / refresh / ID tokens

/oauth2/jwks

JSON Web Key Set — public keys resource servers use to verify signatures

/oauth2/revoke

Token revocation (RFC 7009)

/oauth2/introspect

Token introspection (RFC 7662) — for opaque-token resource servers

/connect/register

OIDC Dynamic Client Registration (enabled via .oidc(…​), off by default)

/userinfo

OIDC UserInfo — claims about the authenticated user, called with the access token

/connect/logout

OIDC RP-initiated logout

/.well-known/openid-configuration

OIDC discovery document — clients read every URL above from here

Beans to implement

@Bean
RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    return new JdbcRegisteredClientRepository(jdbcTemplate);   // InMemoryRegisteredClientRepository for demos
}

@Bean
OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
                                                RegisteredClientRepository clients) {
    return new JdbcOAuth2AuthorizationService(jdbcTemplate, clients);
}

@Bean
OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
                                                             RegisteredClientRepository clients) {
    return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, clients);
}

@Bean
JWKSource<SecurityContext> jwkSource() {
    KeyPair keyPair = generateRsaKey();                        // 2048-bit RSA, loaded from a keystore in prod
    RSAKey rsaKey = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
            .privateKey((RSAPrivateKey) keyPair.getPrivate())
            .keyID(UUID.randomUUID().toString())
            .build();
    return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}

@Bean
JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
    return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

@Bean
AuthorizationServerSettings authorizationServerSettings() {
    return AuthorizationServerSettings.builder()
            .issuer("https://auth.example.com")
            .build();
}

@Bean
OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(AppUserRepository users) {
    return context -> {
        if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
            String username = context.getPrincipal().getName();
            Set<String> roles = users.findByUsername(username)
                    .map(AppUser::getAuthorities).orElse(Set.of());
            context.getClaims().claim("roles", roles);
        }
    };
}

For production, keep the RSA keypair in a keystore (or a KMS / vault) and load it, so restarts and multiple instances share the same signing key.

Registered clients

A RegisteredClient describes one application allowed to request tokens. A confidential client authenticates with a secret; a public client (SPA, mobile, CLI) cannot keep a secret and must use PKCE.

RegisteredClient webApp = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("web-app")
        .clientSecret("{bcrypt}$2a$10$...")                        // client_secret_basic
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.example.com/login/oauth2/code/web-app")
        .postLogoutRedirectUri("https://app.example.com/")
        .scope(OidcScopes.OPENID)
        .scope(OidcScopes.PROFILE)
        .scope("read")
        .clientSettings(ClientSettings.builder()
                .requireAuthorizationConsent(true)
                .build())
        .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(15))
                .refreshTokenTimeToLive(Duration.ofDays(30))
                .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)   // JWT; REFERENCE = opaque
                .build())
        .build();

RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("spa")
        .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)    // public client
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .redirectUri("https://spa.example.com/callback")
        .scope(OidcScopes.OPENID)
        .clientSettings(ClientSettings.builder()
                .requireProofKey(true)                                 // PKCE required
                .build())
        .build();

The same two clients as configuration properties (Boot binds them into an InMemoryRegisteredClientRepository when no RegisteredClientRepository bean is defined):

spring:
  security:
    oauth2:
      authorizationserver:
        issuer: https://auth.example.com
        client:
          web-app:
            registration:
              client-id: web-app
              client-secret: "{bcrypt}$2a$10$..."
              client-authentication-methods: client_secret_basic
              authorization-grant-types: [authorization_code, refresh_token]
              redirect-uris: [https://app.example.com/login/oauth2/code/web-app]
              post-logout-redirect-uris: [https://app.example.com/]
              scopes: [openid, profile, read]
            require-authorization-consent: true
            token:
              access-token-time-to-live: 15m
              refresh-token-time-to-live: 30d
          spa:
            registration:
              client-id: spa
              client-authentication-methods: none
              authorization-grant-types: [authorization_code]
              redirect-uris: [https://spa.example.com/callback]
              scopes: [openid]
            require-proof-key: true

Database model

Spring Authorization Server ships three JDBC scripts on the classpath (org/springframework/security/oauth2/server/authorization/…​):

Script Table Holds

oauth2-registered-client-schema.sql

oauth2_registered_client

one row per registered client (id, secret, grant types, scopes, client_settings / token_settings JSON)

oauth2-authorization-schema.sql

oauth2_authorization

one row per grant — the authorization state plus the authorization-code, access-token, refresh-token, and id-token columns (value, issued/expires, metadata)

oauth2-authorization-consent-schema.sql

oauth2_authorization_consent

one row per (client, principal) — the set of scopes the user has already consented to

Plus the users / authorities tables from Spring Security for the end-user formLogin.

CREATE TABLE oauth2_registered_client (
    id                            varchar(100)  NOT NULL,
    client_id                     varchar(100)  NOT NULL,
    client_id_issued_at           timestamp     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    client_secret                 varchar(200)  DEFAULT NULL,
    client_secret_expires_at      timestamp     DEFAULT NULL,
    client_name                   varchar(200)  NOT NULL,
    client_authentication_methods varchar(1000) NOT NULL,
    authorization_grant_types     varchar(1000) NOT NULL,
    redirect_uris                 varchar(1000) DEFAULT NULL,
    post_logout_redirect_uris     varchar(1000) DEFAULT NULL,
    scopes                        varchar(1000) NOT NULL,
    client_settings               varchar(2000) NOT NULL,
    token_settings                varchar(2000) NOT NULL,
    PRIMARY KEY (id)
);
spring:
  sql:
    init:
      mode: always
      schema-locations:
        - classpath:org/springframework/security/oauth2/server/authorization/oauth2-registered-client-schema.sql
        - classpath:org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql
        - classpath:org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql
        - classpath:org/springframework/security/core/userdetails/jdbc/users.ddl
  datasource:
    url: jdbc:postgresql://localhost:5432/auth
    username: auth
    password: auth

Use spring.sql.init only for dev / demo; in staging and production run these scripts through a migration tool (see Evolving the Database Model) and set mode: never.

Login and logout

.oidc(Customizer.withDefaults()) plus the formLogin(withDefaults()) on the @Order(2) chain gives you a generated login page at /login. To brand it, register your own /login @Controller returning a template and point formLogin().loginPage("/login") at it.

@Controller
public class LoginController {

    @GetMapping("/login")
    public String login() {
        return "login";   // src/main/resources/templates/login.html
    }
}

RP-initiated logout: a client sends the browser to /connect/logout?id_token_hint=…​&post_logout_redirect_uri=…​; the authorization server ends its session and redirects back to a postLogoutRedirectUri registered on that client. Revocation: a client POSTs a token to /oauth2/revoke (with its client credentials) to invalidate it before it expires.

A client round-trip

authorization_code + PKCE by hand, against the running server:

# 1. generate a PKCE pair
CODE_VERIFIER=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')

# 2. open the authorization URL in a browser; log in and consent
open "https://auth.example.com/oauth2/authorize?response_type=code&client_id=spa\
&redirect_uri=https://spa.example.com/callback&scope=openid+profile\
&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"

# 3. exchange the returned ?code=... for tokens
curl -s https://auth.example.com/oauth2/token \
  -d grant_type=authorization_code \
  -d client_id=spa \
  -d redirect_uri=https://spa.example.com/callback \
  -d code_verifier="${CODE_VERIFIER}" \
  -d code="AUTH_CODE_FROM_STEP_2"

# 4. inspect the server's keys and discovery document
curl -s https://auth.example.com/oauth2/jwks
curl -s https://auth.example.com/.well-known/openid-configuration
sequenceDiagram participant B as Browser participant C as Client app participant AS as Authorization server participant RS as Resource server (API) B->>C: click "Log in" C->>B: 302 to /oauth2/authorize (client_id, redirect_uri,
scope, code_challenge, S256) B->>AS: GET /oauth2/authorize AS->>B: 302 to /login (not authenticated) B->>AS: POST /login (username + password) AS->>B: consent screen (first time for this client + scopes) B->>AS: approve AS->>B: 302 to redirect_uri with code=xyz B->>C: GET redirect_uri with code=xyz C->>AS: POST /oauth2/token (code, code_verifier) AS->>C: access_token (JWT) + refresh_token + id_token C->>RS: GET /api/orders with bearer access_token RS->>AS: GET /oauth2/jwks (cached) -- verify signature, iss, aud, exp RS->>C: 200 resource

Social login (Google, Microsoft, Apple)

Account linking, the verified-e-mail trap, per-provider quirks and the broker pattern are covered protocol-side in Social Login and Federation.

Here your app is the client. Add spring-boot-starter-oauth2-client and enable oauth2Login; add oauth2Client(…​) as well only when you also call provider APIs outside an interactive login.

@Bean
SecurityFilterChain socialLogin(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login", "/api/auth/providers").permitAll()
            .anyRequest().authenticated())
        .oauth2Login(oauth2 -> oauth2
            .userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService())))
        .logout(logout -> logout.logoutSuccessUrl("/"));
    return http.build();
}

Properties

spring.security.oauth2.client.registration.<id>. — client-id, client-secret, client-authentication-method, authorization-grant-type, redirect-uri ({baseUrl}/login/oauth2/code/{registrationId}), scope, client-name, provider. spring.security.oauth2.client.provider.<id>. — issuer-uri (discovery), or explicit authorization-uri / token-uri / user-info-uri / jwk-set-uri / user-name-attribute.

Classes involved

ClientRegistration / ClientRegistrationRepository / InMemoryClientRegistrationRepository hold the per-provider config; CommonOAuth2Provider (GOOGLE, GITHUB, FACEBOOK, X, OKTA) supplies preset endpoint URLs. OAuth2LoginAuthenticationFilter handles the callback at /login/oauth2/code/{registrationId}. OidcUserService (for OIDC providers) and DefaultOAuth2UserService (plain OAuth2) fetch the user; they produce an OidcUser / OAuth2User carrying OidcUserAuthority / OAuth2UserAuthority. A GrantedAuthoritiesMapper bean remaps those to your own authorities.

Interface to implement

Delegate to the built-in OidcUserService, then find-or-create a local AppUser and return a DefaultOidcUser with local authorities:

@Bean
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
    OidcUserService delegate = new OidcUserService();
    return userRequest -> {
        OidcUser oidcUser = delegate.loadUser(userRequest);
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String subject = oidcUser.getSubject();
        String email = oidcUser.getEmail();
        boolean emailVerified = Boolean.TRUE.equals(oidcUser.getEmailVerified());

        AppUser appUser = accountService.findOrCreate(registrationId, subject,
                emailVerified ? email : null, oidcUser.getFullName());

        Set<GrantedAuthority> authorities = appUser.getAuthorities().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toCollection(HashSet::new));
        authorities.add(new OidcUserAuthority(oidcUser.getIdToken(), oidcUser.getUserInfo()));

        return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
                StandardClaimNames.SUB);
    };
}

Per-provider config

Google — OIDC discovery works out of the box:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: [openid, profile, email]
        provider:
          google:
            issuer-uri: https://accounts.google.com

With spring-boot-starter-oauth2-client on the classpath, the google registration id also picks up CommonOAuth2Provider.GOOGLE automatically when only client-id / client-secret are set.

Microsoft Entra ID — a custom provider; preferred_username is the stable login name:

spring:
  security:
    oauth2:
      client:
        registration:
          entra:
            client-id: ${ENTRA_CLIENT_ID}
            client-secret: ${ENTRA_CLIENT_SECRET}
            scope: [openid, profile, email]
            client-name: Microsoft
            provider: entra
        provider:
          entra:
            issuer-uri: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/v2.0
            # or explicit:
            # authorization-uri: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize
            # token-uri:         https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token
            # user-info-uri:     https://graph.microsoft.com/oidc/userinfo
            user-name-attribute: preferred_username

spring-cloud-azure-starter-active-directory is the higher-level alternative — it wires app roles, group claims, and On-Behalf-Of flows without hand-writing the provider block.

Apple — "Sign in with Apple" issues no client secret. Authentication is private_key_jwt: a client-assertion JWT signed with the ES256 key downloaded from the Apple developer portal (key id + team id
service id). Apple returns the user’s name and email only on the first authorization, so persist them then; later logins carry only sub.

spring:
  security:
    oauth2:
      client:
        registration:
          apple:
            client-id: ${APPLE_SERVICE_ID}          # the Services ID, not the App ID
            client-authentication-method: private_key_jwt
            authorization-grant-type: authorization_code
            scope: [openid, name, email]
            provider: apple
        provider:
          apple:
            issuer-uri: https://appleid.apple.com
            user-name-attribute: sub

Plain auto-config does not build the ES256 client-assertion, so Apple needs a custom OAuth2AuthorizationCodeGrantRequestEntityConverter (and a matching token-response client) that signs the assertion from your ES256 JWK, wired via a RestClientAuthorizationCodeTokenResponseClient.

Local account model and endpoints

One local user, several linked providers:

CREATE TABLE app_user (
    id           bigserial PRIMARY KEY,
    email        varchar(320) UNIQUE,
    display_name varchar(200),
    enabled      boolean NOT NULL DEFAULT true
);

CREATE TABLE user_identity (
    id               bigserial PRIMARY KEY,
    provider         varchar(50)  NOT NULL,          -- 'google' | 'entra' | 'apple' | 'local'
    provider_user_id varchar(255) NOT NULL,          -- the 'sub' claim
    user_id          bigint       NOT NULL REFERENCES app_user (id),
    linked_at        timestamp    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (provider, provider_user_id)
);
@Entity
@Table(name = "app_user")
public class AppUser {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true)
    private String email;
    private String displayName;
    private boolean enabled = true;
    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "app_user_authority", joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "authority")
    private Set<String> authorities = new HashSet<>();
    // getters / setters
}

@Entity
@Table(name = "user_identity",
       uniqueConstraints = @UniqueConstraint(columnNames = {"provider", "provider_user_id"}))
public class UserIdentity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String provider;
    private String providerUserId;
    @ManyToOne(optional = false)
    @JoinColumn(name = "user_id")
    private AppUser user;
    private Instant linkedAt = Instant.now();
    // getters / setters
}

public interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByEmail(String email);
}

public interface UserIdentityRepository extends JpaRepository<UserIdentity, Long> {
    Optional<UserIdentity> findByProviderAndProviderUserId(String provider, String providerUserId);
}

See Spring Data JPA for the repository mechanics. findOrCreate (called from the OAuth2UserService above) looks up user_identity by (provider, sub), falls back to matching a verified email, and otherwise inserts a new app_user + user_identity row.

@RestController
@RequestMapping("/api")
public class AccountController {

    // InMemoryClientRegistrationRepository implements Iterable<ClientRegistration>; it is what Boot
    // creates when registrations come from spring.security.oauth2.client.registration.* properties.
    // The ClientRegistrationRepository interface itself is not iterable -- inject the concrete type
    // here, or keep your own List<ProviderInfo> if you use a different repository implementation.
    private final InMemoryClientRegistrationRepository clientRegistrations;
    private final AppUserRepository users;

    public AccountController(InMemoryClientRegistrationRepository clientRegistrations, AppUserRepository users) {
        this.clientRegistrations = clientRegistrations;
        this.users = users;
    }

    // login UI reads this to render the provider buttons
    @GetMapping("/auth/providers")
    public List<ProviderInfo> providers() {
        List<ProviderInfo> list = new ArrayList<>();
        clientRegistrations.forEach(reg -> list.add(
                new ProviderInfo(reg.getRegistrationId(), reg.getClientName(),
                        "/oauth2/authorization/" + reg.getRegistrationId())));
        return list;
    }

    @GetMapping("/me")
    public MeResponse me(@AuthenticationPrincipal OidcUser principal) {
        AppUser user = users.findByEmail(principal.getEmail()).orElseThrow();
        return new MeResponse(user.getId(), user.getEmail(), user.getDisplayName(), user.getAuthorities());
    }
}

Do not re-implement the redirect entry point: GET /oauth2/authorization/{registrationId} is built into Spring Security and starts the flow; the callback GET /login/oauth2/code/{registrationId} is handled by OAuth2LoginAuthenticationFilter. POST /logout is the standard Spring Security logout.

Calling provider and downstream APIs

After login, the access token for each provider is stored as an OAuth2AuthorizedClient (keyed by registration id + principal) in an OAuth2AuthorizedClientService / OAuth2AuthorizedClientRepository. An OAuth2AuthorizedClientManager hands it out and refreshes it when expired.

@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrations,
        OAuth2AuthorizedClientRepository authorizedClients) {
    OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
            .authorizationCode()
            .refreshToken()
            .clientCredentials()
            .build();
    DefaultOAuth2AuthorizedClientManager manager =
            new DefaultOAuth2AuthorizedClientManager(clientRegistrations, authorizedClients);
    manager.setAuthorizedClientProvider(provider);
    return manager;
}

// blocking: RestClient + OAuth2ClientHttpRequestInterceptor (Spring Security 7)
@Bean
RestClient googleRestClient(OAuth2AuthorizedClientManager manager) {
    OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
    interceptor.setClientRegistrationIdResolver(request -> "google");
    return RestClient.builder()
            .baseUrl("https://www.googleapis.com")
            .requestInterceptor(interceptor)
            .build();
}

// reactive: WebClient + ServerOAuth2AuthorizedClientExchangeFilterFunction
@Bean
WebClient googleWebClient(ReactiveClientRegistrationRepository clientRegistrations,
                          ServerOAuth2AuthorizedClientRepository authorizedClients) {
    ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
            new ServerOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrations, authorizedClients);
    oauth2.setDefaultClientRegistrationId("google");
    return WebClient.builder()
            .baseUrl("https://www.googleapis.com")
            .filter(oauth2)
            .build();
}

A plain (unauthenticated) RestClient / WebClient GET is enough to read a provider’s jwk-set-uri or /.well-known/openid-configuration.

sequenceDiagram participant B as Browser participant A as Your app (oauth2Login client) participant G as Google (accounts.google.com) participant DB as app_user / user_identity B->>A: GET /oauth2/authorization/google A->>B: 302 to Google authorize (client_id, redirect_uri,
scope openid profile email, state, nonce, PKCE) B->>G: sign in + consent G->>B: 302 to /login/oauth2/code/google (code, state) B->>A: GET /login/oauth2/code/google (code) A->>G: POST token endpoint (code, PKCE verifier) G->>A: id_token + access_token A->>G: GET userinfo (access_token) G->>A: sub, email, email_verified, name A->>DB: find user_identity by (google, sub) opt no local link yet A->>DB: match verified email, else INSERT app_user + user_identity end A->>B: session established, then 302 to original request