Spring Boot – OAuth2

August 2, 20266 min readUpdated 8/18/2026

"Sign in with Google" spares your users another password and spares you storing one. Spring Security handles the protocol; what it cannot decide for you is who this person is in your database — and that is where the bugs and the vulnerabilities live.

The flow, in the terms Spring names things

1. Browser hits  /oauth2/authorization/google
2. Spring redirects to Google with client_id, scope, redirect_uri and state
3. User signs in at Google and consents
4. Google redirects back to /login/oauth2/code/google?code=…&state=…
5. Spring exchanges the code for an access token   ← server-to-server, uses the secret
6. Spring fetches the profile from Google's userinfo endpoint
7. YOUR success handler runs                        ← the only interesting part

Steps 1–6 are the authorization code flow and Spring implements all of them. Note that the code is exchanged server-to-server: the client secret never reaches the browser, which is what distinguishes this from the implicit flow it replaced.

state is CSRF protection for the handshake — Spring generates and verifies it, so it is one thing you do not have to think about.

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
# Get the two values from https://console.cloud.google.com/apis/credentials
# (OAuth 2.0 Client ID, type "Web application"), and register this exact redirect URI:
#
#   http://localhost:8085/login/oauth2/code/google
#
# That path is not arbitrary - it is Spring Security's default
# {baseUrl}/login/oauth2/code/{registrationId}, and Google requires an EXACT match. A
# redirect_uri_mismatch error means these two strings differ, usually by a trailing
# slash or by http vs https.
#
# `google` is a well-known provider: Spring Security already knows its authorization,
# token and user-info URLs, so only the credentials and scopes are needed here.
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:}
spring.security.oauth2.client.registration.google.scope=openid,profile,email

redirect_uri_mismatch is the error everyone hits first, and it is always a literal string difference — a trailing slash, http against https, or 127.0.0.1 against localhost.

For a provider Spring does not know, you supply the endpoints yourself:

spring.security.oauth2.client.provider.acme.authorization-uri=https://acme.example/oauth/authorize
spring.security.oauth2.client.provider.acme.token-uri=https://acme.example/oauth/token
spring.security.oauth2.client.provider.acme.user-info-uri=https://acme.example/userinfo
spring.security.oauth2.client.provider.acme.user-name-attribute=sub

Wiring it into the filter chain

// The OAuth2 handshake endpoints. /oauth2/authorization/** starts the flow
// and /login/oauth2/code/** receives the provider's callback; both must be
// reachable by an unauthenticated browser, which is the whole point.
.requestMatchers("/oauth2/**", "/login/oauth2/**")
.permitAll()
// Enabled only when a provider is actually configured. Calling oauth2Login() without a
// ClientRegistrationRepository fails the context at startup, so this is not merely tidy —
// it is what keeps the default profile bootable.
if (clientRegistrationRepository.getIfAvailable() != null) {
    OAuth2LoginSuccessHandler successHandler = oauth2SuccessHandler.getObject();
    http.oauth2Login(oauth2 -> oauth2.successHandler(successHandler));
}

Boot only builds a ClientRegistrationRepository when the registration properties are present. Injecting it through an ObjectProvider (lesson 6) means one filter chain covers both cases, instead of two profile-specific SecurityConfig classes that then drift apart.

The part that matters: reconciliation

/**
 * <p>Everything up to this point is boilerplate Spring Security handles: the redirect to Google,
 * the code exchange, fetching the profile. What no framework can decide for you is <b>who this
 * person is in YOUR database</b> — and getting that wrong is how one human ends up with two
 * accounts and loses their order history.
 */
@Override
@Transactional
public void onAuthenticationSuccess(
        HttpServletRequest request, HttpServletResponse response, Authentication authentication)
        throws IOException {

    OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal();

    String email = oauthUser.getAttribute("email");
    Boolean emailVerified = oauthUser.getAttribute("email_verified");
    String name = oauthUser.getAttribute("name");

    if (email == null || email.isBlank()) {
        throw ApiException.unauthorized("The identity provider returned no email address");
    }
    if (!Boolean.TRUE.equals(emailVerified)) {
        log.warn("Refused an OAuth2 login for unverified address {}", email);
        throw ApiException.unauthorized("Your email address is not verified with the provider");
    }

    User user = userDAO.findByEmail(email).orElseGet(() -> createFromOAuth(email, name));

    String token = jwtService.generateToken(user);

    String target = successRedirect + "#token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
    getRedirectStrategy().sendRedirect(request, response, target);
}

⚠️ Match on verified email, or you have built account takeover

/**
 * <p>⚠️ <b>The email must be verified by the provider.</b> Matching on an unverified address is an
 * account-takeover vulnerability: an attacker signs up to the identity provider claiming
 * {@code someone@example.com}, and if we trust that claim we hand them the existing account.
 */

This is the single most important paragraph in the lesson. The attack is not subtle: create an account at a provider that does not verify addresses, claim the victim's email, sign in to your app, and the reconciliation step hands over an existing account. Google sets email_verified; a provider that does not is a provider you cannot match on email at all.

Creating the account

/**
 * <p>{@code passwordHash} is left null on purpose, and every password-checking path must treat
 * null as "no password login available" rather than as an empty password. The role is always
 * CUSTOMER — exactly as in {@code /api/auth/register}, because a role that can be influenced
 * from outside is a privilege-escalation bug waiting to happen.
 */
private User createFromOAuth(String email, String name) {
    return userDAO.save(User.builder()
            .email(email)
            .fullName(name == null || name.isBlank() ? email : name)
            .passwordHash(null)
            .role(UserRole.CUSTOMER)
            .build());
}

The null passwordHash is a contract with the rest of the application. Any code path that would treat null as an empty password, or that calls passwordEncoder.matches(candidate, null) without checking, is a way in. Worth a test.

Ending in your own token

/**
 * <p>The rest of this API is stateless and token-based. Ending an OAuth2 login with a session
 * cookie would mean two different authentication mechanisms and two sets of rules. Instead the
 * OAuth2 flow is treated as one more way to prove identity, and the result is the same token
 * {@code /api/auth/login} issues.
 */

OAuth2 is a way of proving who you are, not a replacement for your session model. Once identity is established, issue the same JWT a password login would (lesson 25) and everything downstream is unchanged.

Note where the token goes:

// The token goes in the URL fragment, NOT the query string: a fragment is never sent to the
// server, never lands in an access log and is not sent as a Referer. A query parameter
// would leak the token into every one of those.
String target = successRedirect + "#token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);

The browser keeps a fragment client-side. A query parameter would be written to the web server's access log, the CDN's log, and any Referer header the page later sends — three places a credential should never be.

OAuth2 is not OpenID Connect

Worth being precise, because the two are constantly conflated:

  • OAuth2 is authorization. "This app may read your calendar." It says nothing about who you are.
  • OpenID Connect is a thin layer on top of OAuth2 that adds authentication — an id_token and a standard set of claims including email and email_verified.

That is why the scope above is openid,profile,email. The openid scope is what turns an authorization flow into a login. Without it you get an access token and no reliable identity.

Client, resource server, or both?

StarterYour app is…
oauth2-clientlogging users in via a provider — this lesson
oauth2-resource-servervalidating tokens issued by someone else (Auth0, Keycloak, Cognito)
oauth2-authorization-serverissuing tokens to other apps

If you are adopting a managed identity provider, resource server is usually what you want — the provider handles login and you validate its JWTs, which removes the reconciliation problem and your own token issuance in one move.

What to take from this

  • Spring does the protocol; you do the reconciliation.
  • Match on verified email only. Anything else is account takeover.
  • Never let a role come from the provider.
  • Issue your own token so there is one session model, and return it in the URL fragment.
  • openid scope is what makes it a login rather than an authorization.

Next: async work and thread pools.