Skip to content
CalliCoder

Spring Boot OAuth2 Social Login: Spring Security Tutorial

Spring Boot 15 min read Part of Spring Security with React

Part one of a Spring Security OAuth2 example: registering the providers, mapping three different user-info payloads onto one account, and issuing your own JWT instead of holding a session.

Two regions divided by a boundary with one gate, a keyed token passing through its tumblers.

Spring Boot OAuth2 social login is one of those features that looks like configuration until you try to make three providers behave the same way. The protocol handshake really is a few properties. What takes the time is everything after the callback: Google, Facebook and GitHub each return a different user-info shape, and you have to decide what an “account” means when the same person can arrive through any of them.

This Spring Security OAuth2 example covers the setup and the account model, the half of a working OAuth2 social login example that no amount of configuration will write for you. Written against Spring Boot 3.2, Spring Security 6 and Java 17.

What this Spring Boot OAuth2 social login tutorial builds

A backend that lets a user sign in with any of the three providers and returns a JWT the frontend can use. Concretely:

  1. GET /oauth2/authorize/google redirects to the provider.
  2. The provider calls back to /login/oauth2/code/google.
  3. Spring Security exchanges the code for tokens and fetches the user info.
  4. Our code maps that payload to a local User, creating one on first sign-in.
  5. We issue our own JWT and redirect to the frontend with it.

Step 4 is where the work is. Steps 1–3 are Spring Security doing its job.

Dependencies

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.6</version>
</dependency>

oauth2-client is the one that matters. It is the client half of OAuth2. Do not reach for spring-boot-starter-oauth2-resource-server here; that is for validating tokens other people issued, which is a different job in a later part.

Registering the providers: a three-provider example

Google and Facebook are known to Spring Security, so they need only credentials. GitHub is known too, but its email handling needs a scope most examples omit.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:
              - email
              - profile
          facebook:
            client-id: ${FACEBOOK_CLIENT_ID}
            client-secret: ${FACEBOOK_CLIENT_SECRET}
            scope:
              - email
              - public_profile
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope:
              - user:email
        provider:
          facebook:
            # Facebook returns only id and name unless the fields are requested explicitly.
            user-info-uri: https://graph.facebook.com/me?fields=id,name,email,picture.width(250)

app:
  jwt:
    secret: ${JWT_SECRET}
    expiration-ms: 864000000
  oauth2:
    authorized-redirect-uris:
      - http://localhost:3000/oauth2/redirect

The redirect URI you register with each provider must match what Spring Security serves: {baseUrl}/login/oauth2/code/{registrationId}, so http://localhost:8080/login/oauth2/code/google in development. A mismatch produces a provider-side error page, not a Spring stack trace, which is why it is worth checking first when nothing happens.

Credentials belong in environment variables. A client secret committed to a repository is compromised the moment the repository is shared, and rotating it means going back to three provider consoles.

The account model

The decision that shapes everything: one user, many providers, matched on verified email.

@Entity
@Table(name = "users",
       uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Email @Column(nullable = false)
    private String email;

    private String imageUrl;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private AuthProvider provider;   // GOOGLE, FACEBOOK, GITHUB

    private String providerId;

    // getters and setters omitted
}

public enum AuthProvider { GOOGLE, FACEBOOK, GITHUB }

Matching on email is convenient and has a sharp edge worth stating plainly: if a provider does not verify the email it gives you, account matching becomes an account takeover. Someone registers an unverified account at a provider using your address, signs in, and lands in your user record.

Google returns an email_verified claim. GitHub marks the primary email verified through its API. Facebook does not expose a verification flag at all. Check the flag where it exists, and treat Facebook email as untrusted for matching: link it to an existing account only after the user confirms while signed in.

Normalising three different payloads

Each provider names things differently. One small hierarchy keeps that mess out of the rest of the codebase:

public abstract class OAuth2UserInfo {
    protected final Map<String, Object> attributes;
    protected OAuth2UserInfo(Map<String, Object> attributes) { this.attributes = attributes; }
    public abstract String getId();
    public abstract String getName();
    public abstract String getEmail();
    public abstract String getImageUrl();
}
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
    public GoogleOAuth2UserInfo(Map<String, Object> a) { super(a); }
    public String getId()       { return (String) attributes.get("sub"); }
    public String getName()     { return (String) attributes.get("name"); }
    public String getEmail()    { return (String) attributes.get("email"); }
    public String getImageUrl() { return (String) attributes.get("picture"); }
    public boolean isEmailVerified() {
        return Boolean.TRUE.equals(attributes.get("email_verified"));
    }
}

public class GithubOAuth2UserInfo extends OAuth2UserInfo {
    public GithubOAuth2UserInfo(Map<String, Object> a) { super(a); }
    public String getId()       { return String.valueOf(attributes.get("id")); }
    public String getName()     { return (String) attributes.get("name"); }
    public String getEmail()    { return (String) attributes.get("email"); }  // often null
    public String getImageUrl() { return (String) attributes.get("avatar_url"); }
}

public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
    public FacebookOAuth2UserInfo(Map<String, Object> a) { super(a); }
    public String getId()    { return (String) attributes.get("id"); }
    public String getName()  { return (String) attributes.get("name"); }
    public String getEmail() { return (String) attributes.get("email"); }
    @SuppressWarnings("unchecked")
    public String getImageUrl() {
        Map<String, Object> picture = (Map<String, Object>) attributes.get("picture");
        if (picture == null) return null;
        Map<String, Object> data = (Map<String, Object>) picture.get("data");
        return data == null ? null : (String) data.get("url");
    }
}

Three details that cost people an afternoon each:

  • Google’s stable identifier is sub, not id. It is the OIDC subject claim.
  • GitHub’s id is a number, so casting it to String throws. Use String.valueOf.
  • GitHub’s email is usually null. The public profile omits it unless the user made it public. With the user:email scope you can fetch https://api.github.com/user/emails and take the primary verified one, an extra call, and the only reliable way.

Wiring it into Spring Security

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    public CustomOAuth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(request);   // the provider call happens here
        try {
            return process(request, oAuth2User);
        } catch (AuthenticationException ex) {
            throw ex;
        } catch (Exception ex) {
            // Wrap: anything else would surface as a 500 instead of an auth failure.
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
        }
    }

    private OAuth2User process(OAuth2UserRequest request, OAuth2User oAuth2User) {
        String registrationId = request.getClientRegistration().getRegistrationId();
        OAuth2UserInfo info = OAuth2UserInfoFactory.get(registrationId, oAuth2User.getAttributes());

        if (!StringUtils.hasText(info.getEmail())) {
            throw new OAuth2AuthenticationProcessingException("No email from " + registrationId);
        }

        User user = userRepository.findByEmail(info.getEmail())
                .map(existing -> update(existing, info))
                .orElseGet(() -> register(registrationId, info));

        return UserPrincipal.create(user, oAuth2User.getAttributes());
    }
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler successHandler;

    // constructor omitted

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)   // safe here: stateless, no cookies
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/error", "/oauth2/**", "/login/**").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(o -> o
                .authorizationEndpoint(a -> a.baseUri("/oauth2/authorize"))
                .redirectionEndpoint(r -> r.baseUri("/login/oauth2/code/*"))
                .userInfoEndpoint(u -> u.userService(oAuth2UserService))
                .successHandler(successHandler));
        return http.build();
    }
}

Spring Security 6 uses the lambda DSL; the older http.csrf().disable() chain no longer compiles. And disabling CSRF is only defensible because the session policy is STATELESS. No session cookie means no cross-site request forgery vector. Leave sessions on and you have removed a real protection.

Why issue your own JWT

The success handler mints a token and redirects to the frontend with it:

String token = tokenProvider.createToken(authentication);
String target = UriComponentsBuilder.fromUriString(redirectUri)
        .queryParam("token", token)
        .build().toUriString();
getRedirectStrategy().sendRedirect(request, response, target);

The provider’s own access token is for calling their API. It says nothing about roles or permissions in your system, its lifetime is theirs to decide, and validating it means a network call on every request. Your own JWT carries your user id and your authorities.

Validate redirectUri against the allowlist before redirecting. An unchecked redirect parameter is an open redirect, and here it would leak the token to whatever host an attacker names.

Putting the token in a query string is the simplest way to pass it along, and it does end up in browser history and server logs. A short expiry limits the damage; an httpOnly cookie or a one-time exchange code is the sturdier option.

What comes next

Part two covers the JWT filter that authenticates subsequent requests and the security configuration around it; part three is the React client that starts the flow and collects the token. For username-and-password authentication over the same JWT machinery, see the JWT and React series. Everything above is the durable part of any Spring Boot OAuth2 social login build: the providers change, the normalisation layer and the account model do not.

If you take one thing from this OAuth2 social login example, make it the verified-email rule. Matching accounts on an unverified address is the difference between a convenience feature and an account-takeover path.

Frequently asked questions

Which starter do I need for Spring Boot OAuth2 social login?

spring-boot-starter-oauth2-client. The resource-server starter validates tokens issued elsewhere, which is a different role.

What redirect URI do I register with the provider?

{baseUrl}/login/oauth2/code/{registrationId}, for example http://localhost:8080/login/oauth2/code/google. It must match exactly.

Why does Facebook return only an id and a name?

Its user-info endpoint returns a minimal payload unless you request fields explicitly. Set user-info-uri with a fields= parameter.

Why is the email null when signing in with GitHub?

GitHub omits it from the public profile unless the user made it public. Request the user:email scope and call https://api.github.com/user/emails for the primary verified address.

Is matching accounts on email safe?

Only for providers that verify it. Google exposes email_verified and GitHub marks its primary email verified; Facebook exposes no flag, so treat it as unverified and require confirmation before linking.

Why cast GitHub’s id with String.valueOf?

It arrives as a number in the JSON payload, so a direct cast to String throws a ClassCastException.

Can one user sign in with more than one provider?

Yes, if you model it that way. This part stores a single provider per user; a separate user_connections table keyed by provider and provider id is the fuller design.

Why issue my own JWT instead of using the provider’s token?

The provider’s token authorises calls to their API and carries none of your roles. Your own token is self-contained, so no network call is needed to authorise a request.

Is disabling CSRF safe here?

Only because the session policy is stateless and no session cookie is issued. If you keep sessions or move the token into a cookie, CSRF protection must come back.

Is passing the token in a query string acceptable?

It is the simplest option and it leaks into history and logs. Keep the expiry short, or use an httpOnly cookie or a one-time exchange code instead.

How do I test this locally?

Register http://localhost:8080 callbacks in each provider console. Google and GitHub both accept localhost; Facebook requires the app to be in development mode with your account as a tester.