Spring BootSocial Login
Spring Boot

Social Login

Spring Security OAuth2 Client provides social login through the OAuth2 authorization code flow. Users authenticate with an external provider — Google, GitHub, Facebook, or any custom OAuth2/OIDC server — and Spring Security handles the redirect, token exchange, and principal mapping automatically. This entry covers the OAuth2 client setup, custom OAuth2UserService, user registration on first login, and linking social accounts to existing users.

OAuth2 Client Setup

Add spring-boot-starter-oauth2-client and configure the registered clients in application.yml. Spring Boot auto-configures the OAuth2 login filter chain and the authorization endpoint. Each provider needs a client-id, client-secret, and the registration name must match a known provider or a custom provider configuration.
XML
<!-- pom.xml -->
<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>

# ── application.yml ────────────────────────────────────────────────────
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id:     ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:
              - openid
              - profile
              - email
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"

          github:
            client-id:     ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope:
              - read:user
              - user:email

          facebook:
            client-id:     ${FACEBOOK_CLIENT_ID}
            client-secret: ${FACEBOOK_CLIENT_SECRET}
            scope:
              - email
              - public_profile

        provider:
          # Built-in providers (google, github, facebook) need no
          # provider block — Spring Security pre-configures them.
          # Custom provider example:
          keycloak:
            issuer-uri: https://keycloak.myapp.com/realms/myapp
            user-name-attribute: preferred_username

SecurityFilterChain for OAuth2 Login

Configure OAuth2 login in SecurityFilterChain. Set a success handler to issue a JWT after the OAuth2 flow completes, and a failure handler to redirect with an error. For REST APIs, disable the default redirect behaviour and return JSON responses instead.
Java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService  oauth2UserService;
    private final OAuth2AuthenticationSuccessHandler successHandler;
    private final OAuth2AuthenticationFailureHandler failureHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http)
            throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/v1/auth/**",
                    "/oauth2/**",
                    "/login/**"
                ).permitAll()
                .anyRequest().authenticated())
            .oauth2Login(oauth2 -> oauth2
                // Where to redirect the user to start OAuth2 flow
                .authorizationEndpoint(ep -> ep
                    .baseUri("/oauth2/authorize"))
                // Where the provider redirects back to
                .redirectionEndpoint(ep -> ep
                    .baseUri("/login/oauth2/code/*"))
                // Custom service to load/create the user
                .userInfoEndpoint(ep -> ep
                    .userService(oauth2UserService))
                // Issue JWT on success
                .successHandler(successHandler)
                // Return error on failure
                .failureHandler(failureHandler))
            .build();
    }
}

// ── Success handler — issues JWT after OAuth2 login ───────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationSuccessHandler
        implements AuthenticationSuccessHandler {

    private final JwtService jwtService;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest  request,
            HttpServletResponse response,
            Authentication      authentication)
            throws IOException {

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        AppUser    appUser    = (AppUser) oAuth2User;

        String accessToken  = jwtService.generateAccessToken(appUser);
        String refreshToken = jwtService.generateRefreshToken(appUser);

        // Redirect to frontend with tokens in query params
        // (or set as HttpOnly cookies for better security)
        String redirectUrl = UriComponentsBuilder
            .fromUriString("https://myapp.com/oauth2/callback")
            .queryParam("token", accessToken)
            .queryParam("refresh", refreshToken)
            .build().toUriString();

        response.sendRedirect(redirectUrl);
    }
}

// ── Failure handler ───────────────────────────────────────────────────
@Component
public class OAuth2AuthenticationFailureHandler
        implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest  request,
            HttpServletResponse response,
            AuthenticationException exception)
            throws IOException {

        String redirectUrl = UriComponentsBuilder
            .fromUriString("https://myapp.com/login")
            .queryParam("error", exception.getMessage())
            .build().toUriString();

        response.sendRedirect(redirectUrl);
    }
}

Custom OAuth2UserService

OAuth2UserService loads user information from the provider's userinfo endpoint and returns an OAuth2User. Override it to register new users on first login, link accounts, or enrich the principal with application-specific data.
Java
// ── OAuth2 user entity ────────────────────────────────────────────────
@Entity
@Table(name = "social_accounts")
@Getter @Setter @NoArgsConstructor
public class SocialAccount {

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

    @Column(nullable = false, length = 30)
    private String provider;        // "google", "github", "facebook"

    @Column(name = "provider_id", nullable = false, length = 255)
    private String providerId;      // subject from the provider

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private AppUser user;

    @Column(name = "linked_at", nullable = false)
    private LocalDateTime linkedAt;
}

// ── Custom OAuth2UserService ───────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final AppUserRepository      userRepo;
    private final SocialAccountRepository socialRepo;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String provider   = userRequest.getClientRegistration()
            .getRegistrationId();
        String providerId = oAuth2User.getName();
        String email      = extractEmail(oAuth2User, provider);
        String name       = extractName(oAuth2User, provider);

        // ── Look up existing social account ───────────────────────────
        Optional<SocialAccount> existingSocial =
            socialRepo.findByProviderAndProviderId(provider, providerId);

        AppUser appUser;
        if (existingSocial.isPresent()) {
            appUser = existingSocial.get().getUser();
            log.debug("Existing social login: {} via {}", email, provider);
        } else {
            // ── Register or link account ───────────────────────────────
            appUser = userRepo.findByEmail(email)
                .orElseGet(() -> registerNewUser(email, name));

            SocialAccount social = new SocialAccount();
            social.setProvider(provider);
            social.setProviderId(providerId);
            social.setUser(appUser);
            social.setLinkedAt(LocalDateTime.now());
            socialRepo.save(social);

            log.info("Linked {} account for: {}", provider, email);
        }

        return appUser;   // AppUser implements OAuth2User
    }

    private AppUser registerNewUser(String email, String name) {
        AppUser user = new AppUser();
        user.setEmail(email);
        user.setName(name);
        user.setPassword("");               // no password for OAuth2 users
        user.setEnabled(true);
        user.setRoles(Set.of("USER"));
        user.setEmailVerified(true);        // provider verified the email
        return userRepo.save(user);
    }

    private String extractEmail(OAuth2User user, String provider) {
        return switch (provider) {
            case "github" -> {
                // GitHub may return null email if set to private
                Object email = user.getAttribute("email");
                yield email != null ? email.toString()
                    : user.getAttribute("login") + "@github.noreply";
            }
            default -> user.getAttribute("email");
        };
    }

    private String extractName(OAuth2User user, String provider) {
        return switch (provider) {
            case "github" -> {
                String name = user.getAttribute("name");
                yield name != null ? name
                    : user.getAttribute("login");
            }
            case "google" -> user.getAttribute("name");
            default       -> user.getAttribute("name");
        };
    }
}

AppUser as OAuth2User and UserDetails

For a seamless integration, make the application's User entity implement both OAuth2User and UserDetails. This allows the same principal to flow through both the OAuth2 login filter chain and JWT-based endpoints without conversion steps.
Java
// ── AppUser implementing both interfaces ─────────────────────────────
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class AppUser implements OAuth2User, UserDetails {

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

    @Column(nullable = false, unique = true, length = 255)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false)
    private boolean enabled = true;

    @Column(name = "email_verified", nullable = false)
    private boolean emailVerified = false;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles",
                     joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "role")
    private Set<String> roles = new HashSet<>();

    // ── OAuth2User ────────────────────────────────────────────────────
    @Transient
    private Map<String, Object> attributes = new HashMap<>();

    @Override
    public Map<String, Object> getAttributes() { return attributes; }

    @Override
    public String getName() { return email; }   // unique identifier

    // ── UserDetails ───────────────────────────────────────────────────
    @Override
    public String getUsername() { return email; }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
            .map(r -> new SimpleGrantedAuthority(
                r.startsWith("ROLE_") ? r : "ROLE_" + r))
            .toList();
    }

    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
}

// ── Controller accessing the OAuth2 principal ─────────────────────────
@RestController
@RequestMapping("/api/v1/profile")
public class ProfileController {

    @GetMapping
    public ResponseEntity<ProfileResponse> me(
            @AuthenticationPrincipal AppUser principal) {
        return ResponseEntity.ok(ProfileResponse.from(principal));
    }
}

Account Linking and Unlinking

Allow authenticated users to link additional social providers to their existing account, and to unlink providers they no longer want. Always ensure at least one login method remains before unlinking.
Java
@RestController
@RequestMapping("/api/v1/account/social")
@RequiredArgsConstructor
public class SocialAccountController {

    private final SocialAccountService socialService;

    // ── List linked providers ─────────────────────────────────────────
    @GetMapping
    public ResponseEntity<List<LinkedProviderResponse>> listLinked(
            @AuthenticationPrincipal AppUser principal) {
        return ResponseEntity.ok(
            socialService.findLinkedProviders(principal.getId()));
    }

    // ── Link a new provider (user must already be authenticated) ──────
    // Redirect to: /oauth2/authorize/{provider}?link=true
    // The OAuth2UserService detects the link=true flag and
    // associates the new social account with the current user

    // ── Unlink a provider ─────────────────────────────────────────────
    @DeleteMapping("/{provider}")
    public ResponseEntity<Void> unlink(
            @PathVariable String provider,
            @AuthenticationPrincipal AppUser principal) {
        socialService.unlink(principal.getId(), provider);
        return ResponseEntity.noContent().build();
    }
}

@Service
@RequiredArgsConstructor
public class SocialAccountService {

    private final SocialAccountRepository socialRepo;
    private final AppUserRepository       userRepo;

    public void unlink(Long userId, String provider) {
        AppUser user = userRepo.findById(userId).orElseThrow();

        long linkedCount = socialRepo.countByUserId(userId);
        boolean hasPassword = !user.getPassword().isEmpty();

        if (linkedCount <= 1 && !hasPassword) {
            throw new BusinessRuleException(
                "UNLINK_DENIED",
                "Cannot unlink the only login method. " +
                "Please set a password first.");
        }

        socialRepo.deleteByUserIdAndProvider(userId, provider);
    }

    public List<LinkedProviderResponse> findLinkedProviders(Long userId) {
        return socialRepo.findByUserId(userId).stream()
            .map(s -> new LinkedProviderResponse(
                s.getProvider(), s.getLinkedAt()))
            .toList();
    }
}