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_usernameSecurityFilterChain 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();
}
}