Spring Boot
Social Login
Social login with Spring Security OAuth2 Client: how the authorization code flow works, setting up Google (OIDC) and GitHub (plain OAuth2), why each needs its own user service, registering and linking users without opening an account-takeover hole, issuing your own tokens safely after login, linking and unlinking providers, and the errors you will meet (redirect_uri_mismatch, authorization_request_not_found).
OAuth2 Client Setup
With spring-boot-starter-oauth2-client on the classpath, Spring Boot builds a ClientRegistration from each entry under spring.security.oauth2.client.registration and wires the login filters for you. Google and GitHub are built-in providers, so you only supply the client id, secret and scopes; the endpoints are already known. A custom OIDC server such as Keycloak needs both a registration and a provider block with its issuer-uri.
Two things in this file matter more than they look. The scopes decide which flow you get: asking for openid makes the login an OpenID Connect (OIDC) login, which Spring handles with a different user service than a plain OAuth2 login (see the provider adapters section). And the redirect URI that Spring uses by default, {baseUrl}/login/oauth2/code/{registrationId}, must be registered character for character in the provider's console, or the provider rejects the login with redirect_uri_mismatch.
Request only the scopes you use, and keep secrets in environment variables, never in the repository.
yaml
# ── Dependencies ─────────────────────────────────────────────────────
# Maven: org.springframework.boot:spring-boot-starter-oauth2-client
# org.springframework.boot:spring-boot-starter-security
# (add spring-boot-starter-data-jpa and a JWT library for the rest of this page)
# ── application.yml ──────────────────────────────────────────────────
spring:
security:
oauth2:
client:
registration:
google: # OIDC provider (scope contains openid)
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
github: # plain OAuth2 provider (no id token)
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user, user:email # user:email lets us read verified emails
# Custom OIDC server, e.g. Keycloak: registration AND provider
# keycloak:
# client-id: myapp
# client-secret: ${KEYCLOAK_SECRET}
# authorization-grant-type: authorization_code
# scope: openid, profile, email
provider:
# keycloak:
# issuer-uri: https://keycloak.example.com/realms/myapp
server:
forward-headers-strategy: framework # behind a TLS-terminating proxy, so that
# {baseUrl} becomes https://... and not http://
app:
frontend-url: https://myapp.com
# ── Provider console settings ────────────────────────────────────────
# Authorized redirect URI (exact match, one per environment):
# https://myapp.com/login/oauth2/code/google
# https://myapp.com/login/oauth2/code/github
# http://localhost:8080/login/oauth2/code/google (local development)
#
# Browser starts the login at: /oauth2/authorization/google
# /oauth2/authorization/githubTwo Filter Chains: The Login Flow and the API
The OAuth2 login is a browser redirect dance. Spring generates a random state value (and a PKCE challenge), stores it, sends the user to the provider, and checks it again when the user comes back to /login/oauth2/code/{id}. By default that stored state lives in the HTTP session. If the whole application is configured STATELESS, the state can be lost and the callback fails with authorization_request_not_found.
A clean way to keep a stateless API and a working login is two SecurityFilterChain beans. The first matches only the OAuth2 endpoints and is allowed a short-lived session; the second protects /api/** with JWTs and no session. Keep Spring's default endpoint URLs: changing the authorization base URI also changes the URL users start from and invites mismatches with the provider's redirect settings.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// ── Chain 1: the redirect dance (needs a short-lived session) ─────
@Bean
@Order(1)
SecurityFilterChain oauth2Chain(HttpSecurity http,
CustomOAuth2UserService oauth2UserService, // GitHub-style
CustomOidcUserService oidcUserService, // Google-style (openid)
OAuth2SuccessHandler successHandler,
OAuth2FailureHandler failureHandler) throws Exception {
http.securityMatcher("/oauth2/**", "/login/oauth2/**")
.authorizeHttpRequests(a -> a.anyRequest().permitAll())
.oauth2Login(o -> o
.userInfoEndpoint(u -> u
.userService(oauth2UserService) // plain OAuth2 providers
.oidcUserService(oidcUserService)) // OIDC providers
.successHandler(successHandler)
.failureHandler(failureHandler));
return http.build();
}
// ── Chain 2: the API (stateless, JWT) ─────────────────────────────
@Bean
@Order(2)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable) // header-token API
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated());
// .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// (JWT filter: see the Security Filters page)
return http.build();
}
}
// How the pieces connect:
// 1. Browser GET /oauth2/authorization/google
// -> Spring stores state in the session, redirects to Google
// 2. Google authenticates the user, redirects to /login/oauth2/code/google?code=...&state=...
// 3. Spring checks state, exchanges the code for tokens (server to server)
// 4. Our user service runs (OidcUserService for Google, OAuth2UserService for GitHub)
// 5. Success handler issues OUR tokens and redirects to the frontendDomain Model: Users and Social Accounts
Keep the JPA entities separate from Spring's security types. An entity that also implements OAuth2User and UserDetails mixes persistence with security, forces odd choices (a @Transient attributes map that is lost when the entity is reloaded) and cannot be returned from OidcUserService, which must produce an OidcUser. Spring builds the security principal; your entities just store data.
Two details protect you from bugs later. A user who only ever logs in with a provider has no password, so store null in the password column and expose hasPassword(), rather than an empty string that pretends to be a hash. And SocialAccount needs a unique constraint on (provider, provider_id): the provider's stable subject id is the identity, and the database must make a duplicate impossible even when two logins race. Use the provider's stable id (Google's sub, GitHub's numeric id), never the email or username, which can change.
Java
@Entity
@Table(name = "users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String email; // store lower-case
@Column(name = "password_hash")
private String passwordHash; // NULL for social-only users
@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; // verified by US or by a trusted provider
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
public boolean hasPassword() {
return passwordHash != null && !passwordHash.isBlank();
}
protected AppUser() { }
public AppUser(String email, String name) {
this.email = email.toLowerCase(Locale.ROOT);
this.name = name;
}
// getters and setters omitted
}
@Entity
@Table(name = "social_accounts",
uniqueConstraints = {
// one row per provider identity: blocks duplicates, even under a race
@UniqueConstraint(columnNames = {"provider", "provider_id"}),
// a user links each provider at most once
@UniqueConstraint(columnNames = {"user_id", "provider"})
})
public class SocialAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 30)
private String provider; // "google", "github"
@Column(name = "provider_id", nullable = false)
private String providerId; // Google "sub", GitHub numeric id
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private AppUser user;
@Column(name = "linked_at", nullable = false)
private Instant linkedAt = Instant.now();
protected SocialAccount() { }
public SocialAccount(AppUser user, String provider, String providerId) {
this.user = user;
this.provider = provider;
this.providerId = providerId;
}
// getters omitted
}
public interface SocialAccountRepository extends JpaRepository<SocialAccount, Long> {
@EntityGraph(attributePaths = "user") // load the user in the same query
Optional<SocialAccount> findWithUserByProviderAndProviderId(String provider,
String providerId);
long countByUserId(Long userId);
boolean existsByUserIdAndProvider(Long userId, String provider);
List<SocialAccount> findByUserId(Long userId);
void deleteByUserIdAndProvider(Long userId, String provider);
}The Login Service: Where Account Security Is Decided
All the decisions that matter live in one service that does not depend on any provider, so you can read it, and test it, in one place.
The central danger is account takeover through email matching. A tempting rule is "find the user with this email, or create one, and attach the provider". It is unsafe unless the provider has verified that email. Some providers return emails the user never confirmed, and GitHub lets people set any public email on their profile. An attacker who signs up at a provider with your victim's email could then log into the victim's account on your site.
So the rules are: use the provider's stable id as identity; accept a provider email only when it is verified; auto-link to an existing local account only if that account's email was also verified locally (otherwise an attacker could pre-register the victim's email with a password and wait for the victim to log in with Google); and let an already logged-in user explicitly link a provider they control, whatever its email.
Java
@Service
public class SocialLoginService {
private final AppUserRepository users;
private final SocialAccountRepository socials;
public SocialLoginService(AppUserRepository users, SocialAccountRepository socials) {
this.users = users;
this.socials = socials;
}
/**
* @param email email supplied by the provider (may be null)
* @param emailVerified true only if the PROVIDER confirmed the email
* @param linkToUserId set when a logged-in user is linking a provider
*/
@Transactional
public AppUser login(String provider, String providerId, String email,
boolean emailVerified, String name, Long linkToUserId) {
// 1. Known provider identity: log that user in
Optional<SocialAccount> existing =
socials.findWithUserByProviderAndProviderId(provider, providerId);
if (existing.isPresent()) {
AppUser owner = existing.get().getUser();
if (linkToUserId != null && !owner.getId().equals(linkToUserId)) {
throw error("account_already_linked",
"This account is already linked to another user");
}
return owner;
}
// 2. Explicit linking by an authenticated user (email may differ)
if (linkToUserId != null) {
AppUser target = users.findById(linkToUserId)
.orElseThrow(() -> error("link_failed", "User not found"));
return attach(target, provider, providerId);
}
// 3. Unknown identity: only a provider-verified email is trusted
if (email == null || !emailVerified) {
throw error("email_not_verified",
"The provider did not return a verified email address");
}
AppUser user = users.findByEmail(email.toLowerCase(Locale.ROOT))
.map(found -> {
// Pre-hijacking guard: never merge into an unverified local account
if (!found.isEmailVerified()) {
throw error("email_not_verified",
"Verify your email or log in with your password first");
}
return found;
})
.orElseGet(() -> register(email, name));
return attach(user, provider, providerId);
}
private AppUser register(String email, String name) {
AppUser user = new AppUser(email, name != null ? name : email);
user.setEmailVerified(true); // the provider verified it
user.setRoles(Set.of("USER"));
return users.save(user);
}
private AppUser attach(AppUser user, String provider, String providerId) {
socials.save(new SocialAccount(user, provider, providerId));
return user;
}
private static OAuth2AuthenticationException error(String code, String description) {
return new OAuth2AuthenticationException(
new OAuth2Error(code, description, null), description);
}
public static List<GrantedAuthority> authorities(AppUser user) {
return user.getRoles().stream()
.map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r))
.toList();
}
}
// Concurrency: two simultaneous first logins both pass the checks; the unique
// constraints make the second insert fail with DataIntegrityViolationException,
// which the failure handler reports as a generic login error. No duplicates.Provider Adapters: OIDC (Google) and OAuth2 (GitHub)
Spring uses two different services depending on the flow. If the requested scopes include openid, the login is OIDC and Spring calls an OidcUserService, which returns an OidcUser built from the ID token. Otherwise it calls an OAuth2UserService, which calls the provider's user-info endpoint. A common mistake is to customise only userInfoEndpoint().userService(...) and then wonder why the code never runs for Google: with the openid scope it is bypassed entirely. Register both.
Each provider also describes email verification differently. For Google (OIDC), the email_verified claim says whether the address is confirmed. For GitHub, the email field of the user object is often empty or unverified, so you call the /user/emails endpoint with the access token (which is why the user:email scope is requested) and use the entry that is both primary and verified. Never invent a placeholder email: a made-up address can collide with real ones and breaks the rules in the previous section. If there is no verified email, pass null and let the login service reject it.
The adapters return Spring's default principal types, and the success handler later finds your user by provider id.
Java
// ── Reads the id of a logged-in user who started a "link provider" flow ─
@Component
public class LinkIntent {
private final JwtService jwtService;
public LinkIntent(JwtService jwtService) { this.jwtService = jwtService; }
public Long currentUserId() {
if (!(RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attrs)) {
return null;
}
Cookie cookie = WebUtils.getCookie(attrs.getRequest(), "link_token");
// parseLinkToken: verifies signature, expiry and purpose=link;
// returns the user id, or null if invalid
return cookie == null ? null : jwtService.parseLinkToken(cookie.getValue());
}
}
// ── Google (OIDC) ────────────────────────────────────────────────────
@Service
public class CustomOidcUserService extends OidcUserService {
private final SocialLoginService socialLogin;
private final LinkIntent linkIntent;
public CustomOidcUserService(SocialLoginService socialLogin, LinkIntent linkIntent) {
this.socialLogin = socialLogin;
this.linkIntent = linkIntent;
}
@Override
@Transactional
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
OidcUser oidc = super.loadUser(request);
AppUser user = socialLogin.login(
request.getClientRegistration().getRegistrationId(),
oidc.getSubject(), // stable id
oidc.getEmail(),
Boolean.TRUE.equals(oidc.getEmailVerified()), // email_verified claim
oidc.getFullName(),
linkIntent.currentUserId());
return new DefaultOidcUser(SocialLoginService.authorities(user),
oidc.getIdToken(), oidc.getUserInfo());
}
}
// ── GitHub (plain OAuth2) ────────────────────────────────────────────
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@JsonIgnoreProperties(ignoreUnknown = true)
record GithubEmail(String email, boolean primary, boolean verified) { }
private final SocialLoginService socialLogin;
private final LinkIntent linkIntent;
private final RestClient github = RestClient.create();
public CustomOAuth2UserService(SocialLoginService socialLogin, LinkIntent linkIntent) {
this.socialLogin = socialLogin;
this.linkIntent = linkIntent;
}
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User gh = super.loadUser(request);
String provider = request.getClientRegistration().getRegistrationId();
if (!provider.equals("github")) {
// Decide per provider whether its email is trustworthy before adding it
throw new OAuth2AuthenticationException(
new OAuth2Error("unsupported_provider"), "Unsupported provider");
}
String verifiedEmail = primaryVerifiedEmail(request.getAccessToken().getTokenValue());
String name = gh.getAttribute("name");
if (name == null) name = gh.getAttribute("login");
AppUser user = socialLogin.login(
provider,
gh.getName(), // numeric id, stable
verifiedEmail,
verifiedEmail != null,
name,
linkIntent.currentUserId());
String nameKey = request.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
return new DefaultOAuth2User(SocialLoginService.authorities(user),
gh.getAttributes(), nameKey);
}
private String primaryVerifiedEmail(String accessToken) {
List<GithubEmail> emails = github.get()
.uri("https://api.github.com/user/emails")
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.body(new ParameterizedTypeReference<List<GithubEmail>>() { });
if (emails == null) return null;
return emails.stream()
.filter(e -> e.primary() && e.verified())
.map(GithubEmail::email)
.findFirst().orElse(null);
}
}Success and Failure Handlers
After a successful login, the usual next step is to hand the browser your own tokens. Do not put them in the redirect URL: a URL is stored in browser history, sent in the Referer header to any third-party resource on the next page, and written to server and proxy logs. Anyone who sees the URL has a working token.
Instead, put the long-lived refresh token in an HttpOnly, Secure cookie, so JavaScript cannot read it, and redirect to the frontend with nothing sensitive in the URL. The frontend then calls your refresh endpoint, which reads the cookie and returns a short-lived access token in the response body. Keep the redirect target in configuration, never taken from a request parameter, or it becomes an open redirect.
The failure handler should not echo exception messages into the URL: they can reveal internals and are not URL-safe. Map errors to a small set of known codes and let the frontend show its own message. Log the real exception on the server.
The provider-side session is no longer needed once you have issued your own tokens, so invalidate it.
Java
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final SocialAccountRepository socials;
private final JwtService jwtService;
private final String frontendUrl;
public OAuth2SuccessHandler(SocialAccountRepository socials, JwtService jwtService,
@Value("${app.frontend-url}") String frontendUrl) {
this.socials = socials;
this.jwtService = jwtService;
this.frontendUrl = frontendUrl;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication;
// Same provider id the user services stored (Google "sub", GitHub id)
SocialAccount social = socials.findWithUserByProviderAndProviderId(
token.getAuthorizedClientRegistrationId(),
token.getPrincipal().getName()).orElseThrow();
AppUser user = social.getUser();
// Refresh token: HttpOnly cookie, never in the URL
ResponseCookie refresh = ResponseCookie
.from("refresh_token", jwtService.generateRefreshToken(user))
.httpOnly(true).secure(true)
.sameSite("Lax") // "None" if the frontend is on another site
.path("/api/v1/auth")
.maxAge(Duration.ofDays(7))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, refresh.toString());
// Clear the one-time link cookie and the temporary OAuth2 session
response.addHeader(HttpHeaders.SET_COOKIE, ResponseCookie.from("link_token", "")
.path("/login/oauth2/code").maxAge(0).build().toString());
HttpSession session = request.getSession(false);
if (session != null) session.invalidate();
// Nothing secret in the URL: the frontend calls POST /api/v1/auth/refresh
response.sendRedirect(frontendUrl + "/oauth2/callback");
}
}
@Component
public class OAuth2FailureHandler implements AuthenticationFailureHandler {
private static final Logger log = LoggerFactory.getLogger(OAuth2FailureHandler.class);
private static final Set<String> KNOWN_CODES = Set.of(
"access_denied", "email_not_verified", "account_already_linked", "link_failed");
private final String frontendUrl;
public OAuth2FailureHandler(@Value("${app.frontend-url}") String frontendUrl) {
this.frontendUrl = frontendUrl;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
log.warn("OAuth2 login failed: {}", exception.getMessage()); // detail stays in the log
String code = "login_failed";
if (exception instanceof OAuth2AuthenticationException e
&& KNOWN_CODES.contains(e.getError().getErrorCode())) {
code = e.getError().getErrorCode();
}
String url = UriComponentsBuilder.fromUriString(frontendUrl + "/login")
.queryParam("error", code)
.build().encode().toUriString(); // encode(): values are URL-escaped
response.sendRedirect(url);
}
}Linking and Unlinking Providers
Linking is harder than it looks because the login itself is a browser navigation. A normal browser redirect cannot carry your Authorization header, and the callback request from the provider knows nothing about who was logged in. A "?link=true" query parameter does not survive the round trip either.
A reliable pattern: an authenticated API call (which does carry the JWT) creates a short-lived, signed link token for the current user and stores it in an HttpOnly cookie scoped to the callback path, then returns the authorization URL for the frontend to navigate to. When the provider redirects back, the cookie is sent along (SameSite=Lax cookies are included on top-level GET navigations), LinkIntent reads it, and the login service attaches the provider to that user instead of creating or matching one.
For unlinking, make sure the user keeps a way to log in: never remove the last provider from an account without a password. Lock the user row during the check, otherwise two concurrent requests can each see "two providers left" and both delete one. Consider requiring recent re-authentication for this action.
Java
@RestController
@RequestMapping("/api/v1/account/social")
public class SocialAccountController {
private static final Set<String> PROVIDERS = Set.of("google", "github");
private final SocialAccountService socialService;
private final JwtService jwtService;
public SocialAccountController(SocialAccountService socialService, JwtService jwtService) {
this.socialService = socialService;
this.jwtService = jwtService;
}
@GetMapping
public List<LinkedProviderResponse> listLinked(
@AuthenticationPrincipal AppUserDetails principal) { // set by your JWT filter
return socialService.findLinkedProviders(principal.getId());
}
// Step 1 of linking: authenticated call, returns where to send the browser
@PostMapping("/{provider}/link")
public Map<String, String> startLink(@PathVariable String provider,
@AuthenticationPrincipal AppUserDetails principal,
HttpServletResponse response) {
if (!PROVIDERS.contains(provider)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown provider");
}
ResponseCookie cookie = ResponseCookie
.from("link_token", jwtService.generateLinkToken(principal.getId()))
.httpOnly(true).secure(true).sameSite("Lax")
.path("/login/oauth2/code") // only sent to the OAuth2 callback
.maxAge(Duration.ofMinutes(5)) // short-lived
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return Map.of("url", "/oauth2/authorization/" + provider); // frontend navigates here
}
@DeleteMapping("/{provider}")
public ResponseEntity<Void> unlink(@PathVariable String provider,
@AuthenticationPrincipal AppUserDetails principal) {
socialService.unlink(principal.getId(), provider);
return ResponseEntity.noContent().build();
}
}
@Service
public class SocialAccountService {
private final SocialAccountRepository socials;
private final AppUserRepository users;
public SocialAccountService(SocialAccountRepository socials, AppUserRepository users) {
this.socials = socials;
this.users = users;
}
@Transactional
public void unlink(Long userId, String provider) {
AppUser user = users.findByIdForUpdate(userId).orElseThrow(); // row lock
if (!socials.existsByUserIdAndProvider(userId, provider)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Provider not linked");
}
if (socials.countByUserId(userId) <= 1 && !user.hasPassword()) {
throw new BusinessRuleException("UNLINK_DENIED",
"Cannot unlink the only login method. Set a password first.");
}
socials.deleteByUserIdAndProvider(userId, provider);
}
@Transactional(readOnly = true)
public List<LinkedProviderResponse> findLinkedProviders(Long userId) {
return socials.findByUserId(userId).stream()
.map(s -> new LinkedProviderResponse(s.getProvider(), s.getLinkedAt()))
.toList();
}
}
// In AppUserRepository:
// @Lock(LockModeType.PESSIMISTIC_WRITE)
// @Query("select u from AppUser u where u.id = :id")
// Optional<AppUser> findByIdForUpdate(@Param("id") Long id);
//
// In JwtService (assumed helpers): generateLinkToken(userId) creates a token valid
// for 5 minutes with a claim purpose=link; parseLinkToken(token) returns the user id,
// or null when the signature, expiry or purpose is wrong.Testing the Account Rules
You cannot unit-test a real Google login, but you can, and should, test the part that decides who gets which account. Because SocialLoginService has no dependency on any provider, plain Mockito tests cover the dangerous cases: unverified emails, unverified local accounts, new registrations, and linking attempts. These tests are the safety net for the account-takeover rules.
For endpoints that sit behind a login, spring-security-test also offers an oauth2Login() MockMvc post-processor that fakes an OAuth2 principal without calling a provider.
Java
@ExtendWith(MockitoExtension.class)
class SocialLoginServiceTest {
@Mock AppUserRepository users;
@Mock SocialAccountRepository socials;
@InjectMocks SocialLoginService service;
@Test
void unverifiedProviderEmailIsRejected() {
when(socials.findWithUserByProviderAndProviderId("github", "42"))
.thenReturn(Optional.empty());
assertThrows(OAuth2AuthenticationException.class, () ->
service.login("github", "42", "victim@example.com", false, "Eve", null));
verify(users, never()).save(any()); // nothing created
}
@Test
void verifiedEmailMustNotMergeIntoUnverifiedLocalAccount() {
AppUser local = new AppUser("victim@example.com", "Victim");
local.setEmailVerified(false); // registered with a password, never confirmed
when(socials.findWithUserByProviderAndProviderId("google", "sub-1"))
.thenReturn(Optional.empty());
when(users.findByEmail("victim@example.com")).thenReturn(Optional.of(local));
assertThrows(OAuth2AuthenticationException.class, () ->
service.login("google", "sub-1", "victim@example.com", true, "Eve", null));
verify(socials, never()).save(any());
}
@Test
void newVerifiedUserIsRegisteredAndLinked() {
when(socials.findWithUserByProviderAndProviderId("google", "sub-2"))
.thenReturn(Optional.empty());
when(users.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(users.save(any())).thenAnswer(inv -> inv.getArgument(0));
AppUser user = service.login("google", "sub-2", "new@example.com", true, "New", null);
assertTrue(user.isEmailVerified());
verify(socials).save(any(SocialAccount.class));
}
@Test
void linkingAnIdentityOwnedByAnotherUserIsRejected() {
AppUser owner = mock(AppUser.class);
when(owner.getId()).thenReturn(1L);
SocialAccount existing = new SocialAccount(owner, "github", "42");
when(socials.findWithUserByProviderAndProviderId("github", "42"))
.thenReturn(Optional.of(existing));
assertThrows(OAuth2AuthenticationException.class, () ->
service.login("github", "42", "a@example.com", true, "A", 2L)); // user 2 tries
}
}
// Protected endpoints in MockMvc, without a provider:
// mvc.perform(get("/api/v1/profile").with(oauth2Login()))Common Problems and Fixes
Social login errors are usually configuration mismatches between your application and the provider. Match the symptom below to its cause.
Java
// ── 1. redirect_uri_mismatch (error page at the provider) ────────────
// Cause: the redirect URI sent by Spring differs from the one registered.
// Behind a proxy Spring may build http:// instead of https://.
// Fix: register exactly {baseUrl}/login/oauth2/code/{registrationId} for each
// environment; set server.forward-headers-strategy=framework behind a proxy.
// ── 2. [authorization_request_not_found] on the callback ─────────────
// Cause: the OAuth2 state (stored in the session) is gone when the user returns:
// - the login runs under SessionCreationPolicy.STATELESS
// - several instances without sticky sessions or Spring Session
// - the login started on localhost and the callback is on 127.0.0.1
// (different host, different cookie)
// Fix: use the two-chain setup; sticky sessions or shared session store;
// use the same host everywhere.
// ── 3. My CustomOAuth2UserService never runs for Google ──────────────
// Cause: scope contains openid, so Spring uses the OIDC service.
// Fix: .userInfoEndpoint(u -> u.userService(...).oidcUserService(...))
// ── 4. GitHub login works but the email is null ──────────────────────
// Cause: the user keeps their email private; /user response has email=null.
// Fix: request scope user:email and call GET https://api.github.com/user/emails,
// then take the entry that is primary AND verified.
// ── 5. LazyInitializationException in the success handler ────────────
// Cause: SocialAccount.user is LAZY and the handler runs outside a transaction.
// Fix: load with an entity graph (findWithUserByProviderAndProviderId), as above.
// ── 6. invalid_client / 401 during the token exchange ────────────────
// Cause: wrong client secret, or an unset environment variable
// (placeholder resolves to an empty string).
// Fix: print the registration at startup (without the secret); check the env var.
// ── 7. Two accounts for the same person / someone logged into another account ─
// Cause: matching users by email without checking that the provider verified it,
// or using the email as the provider identity.
// Fix: identity = (provider, subject id); trust verified emails only;
// see the login service section.