OAuth2 & Social Login in Spring Boot

Introduction
"Sign in with Google" and "Sign in with GitHub" buttons are everywhere. Users expect social login — it removes the friction of creating yet another username and password. OAuth2 makes this possible securely, and Spring Boot makes it surprisingly easy to implement.
What You'll Learn
✅ Understand OAuth2 protocol and authorization code flow
✅ Set up Google and GitHub as OAuth2 providers
✅ Configure Spring Security OAuth2 Client
✅ Build a custom OAuth2 user service for user mapping
✅ Auto-register users on first social login
✅ Link multiple social accounts to one user
✅ Combine OAuth2 social login with JWT token authentication
✅ Integrate OAuth2 login with a frontend SPA
✅ Test OAuth2 flows in integration tests
✅ Apply production security best practices
Prerequisites
- Java 17+ installed
- Spring Boot basics (Getting Started guide)
- Spring Security & JWT fundamentals (JWT Authentication guide)
- A Google Cloud account and a GitHub account (for OAuth credentials)
1. Understanding OAuth2
What is OAuth2?
OAuth 2.0 is an authorization framework that lets users grant third-party applications limited access to their accounts on another service — without sharing their password.
When a user clicks "Sign in with Google," your application never sees their Google password. Instead, Google confirms their identity and sends your app a token with the user's profile information.
OAuth2 Roles
| Role | Description | Example |
|---|---|---|
| Resource Owner | The user who owns the data | The person clicking "Sign in with Google" |
| Client | Your application requesting access | Your Spring Boot app |
| Authorization Server | Issues tokens after authenticating | Google's OAuth server |
| Resource Server | Hosts the protected user data | Google's user profile API |
Authorization Code Flow
The Authorization Code flow is the standard for server-side web applications and SPAs. It's the most secure OAuth2 flow for social login:
Why Authorization Code flow?
- The authorization code is exchanged server-to-server — never exposed to the browser
- The access token stays on your server — reducing the risk of token theft
- Supports refresh tokens for long-lived sessions
OAuth2 vs JWT — When to Use Which
| Aspect | OAuth2 (Social Login) | JWT (Custom Auth) |
|---|---|---|
| Use case | "Sign in with Google/GitHub" | Custom username/password login |
| Who manages passwords? | The provider (Google, GitHub) | You (your database) |
| User registration | Simpler — no password to manage | You handle everything |
| Trust model | Trust the provider | Trust your own server |
| Best for | Consumer apps, reducing friction | Internal apps, full control |
In practice, most applications combine both: OAuth2 for social login AND JWT for API authentication. Users sign in via Google → your app issues a JWT → the JWT authenticates subsequent API requests.
2. Setting Up OAuth2 Providers
Before writing any code, you need OAuth2 credentials from each provider.
Google OAuth2 Setup
- Go to Google Cloud Console
- Create a new project (or select existing)
- Navigate to APIs & Services → Credentials
- Click Create Credentials → OAuth client ID
- Select Web application
- Add Authorized redirect URIs:
http://localhost:8080/login/oauth2/code/google - Copy the Client ID and Client Secret
Important: The redirect URI must match exactly. Spring Boot's default pattern is
/login/oauth2/code/{registrationId}.
GitHub OAuth App Setup
- Go to GitHub Developer Settings
- Click New OAuth App
- Fill in:
- Application name: Your App Name
- Homepage URL:
http://localhost:8080 - Authorization callback URL:
http://localhost:8080/login/oauth2/code/github
- Click Register application
- Copy the Client ID and generate a Client Secret
Storing Credentials Securely
Never hardcode secrets in your source code. Use environment variables:
# .env (add to .gitignore!)
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret3. Spring Security OAuth2 Client Setup
Adding Dependencies
Add the OAuth2 Client starter to your pom.xml:
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Client - adds social login support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- JPA for user storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT Support (for issuing tokens after OAuth2 login) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok (optional) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>Application Configuration
Configure both OAuth2 providers in application.yml:
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope:
- user:email
- read:user
datasource:
url: jdbc:postgresql://localhost:5432/oauth2_demo
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: update
show-sql: true
app:
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-for-jwt-signing-min-32-chars}
expiration: 86400000 # 24 hours
oauth2:
authorized-redirect-uri: http://localhost:3000/oauth2/redirectNote: Spring Boot auto-configures Google and GitHub providers. You only need
client-id,client-secret, andscope. The authorization URL, token URL, and user-info URL are pre-configured.
What Spring Boot Does Automatically
With just the dependency and properties above, Spring Boot:
- Creates login endpoints at
/oauth2/authorization/googleand/oauth2/authorization/github - Handles the OAuth2 callback at
/login/oauth2/code/googleand/login/oauth2/code/github - Exchanges the authorization code for an access token
- Fetches user info from the provider
- Creates an
OAuth2AuthenticationTokenin the SecurityContext
You can test this immediately — visit http://localhost:8080/oauth2/authorization/google and you'll be redirected to Google's login page.
4. Custom OAuth2 User Service
The default behavior logs users in but doesn't persist them to your database. Let's build a custom user service.
User Entity with Provider Support
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
private String imageUrl;
@Column(nullable = false)
private Boolean emailVerified = false;
private String password; // null for OAuth2 users
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider;
private String providerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role = Role.USER;
}AuthProvider Enum
public enum AuthProvider {
LOCAL,
GOOGLE,
GITHUB
}Role Enum
public enum Role {
USER,
ADMIN
}User Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Boolean existsByEmail(String email);
}OAuth2 User Info Extraction
Each provider returns user data in a different format. Let's create an abstraction:
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}Google user info:
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}GitHub user info:
public class GitHubOAuth2UserInfo extends OAuth2UserInfo {
public GitHubOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("avatar_url");
}
}Factory to create the right info extractor:
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(
String registrationId, Map<String, Object> attributes) {
if (registrationId.equalsIgnoreCase(AuthProvider.GOOGLE.name())) {
return new GoogleOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.GITHUB.name())) {
return new GitHubOAuth2UserInfo(attributes);
} else {
throw new OAuth2AuthenticationProcessingException(
"Login with " + registrationId + " is not supported");
}
}
}Custom OAuth2 User Service
This is the core component. It processes the OAuth2 login and registers/updates users:
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
try {
return processOAuth2User(userRequest, oAuth2User);
} catch (Exception ex) {
throw new OAuth2AuthenticationProcessingException(
ex.getMessage(), ex);
}
}
private OAuth2User processOAuth2User(
OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String registrationId = userRequest.getClientRegistration()
.getRegistrationId();
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory
.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes());
if (oAuth2UserInfo.getEmail() == null
|| oAuth2UserInfo.getEmail().isEmpty()) {
throw new OAuth2AuthenticationProcessingException(
"Email not found from OAuth2 provider");
}
Optional<User> userOptional = userRepository
.findByEmail(oAuth2UserInfo.getEmail());
User user;
if (userOptional.isPresent()) {
user = userOptional.get();
if (!user.getProvider().name()
.equalsIgnoreCase(registrationId)) {
throw new OAuth2AuthenticationProcessingException(
"You're signed up with " + user.getProvider()
+ ". Please use your " + user.getProvider()
+ " account to login.");
}
user = updateExistingUser(user, oAuth2UserInfo);
} else {
user = registerNewUser(registrationId, oAuth2UserInfo);
}
return new CustomUserPrincipal(user, oAuth2User.getAttributes());
}
private User registerNewUser(
String registrationId, OAuth2UserInfo oAuth2UserInfo) {
User user = new User();
user.setProvider(AuthProvider.valueOf(registrationId.toUpperCase()));
user.setProviderId(oAuth2UserInfo.getId());
user.setName(oAuth2UserInfo.getName());
user.setEmail(oAuth2UserInfo.getEmail());
user.setImageUrl(oAuth2UserInfo.getImageUrl());
user.setEmailVerified(true);
return userRepository.save(user);
}
private User updateExistingUser(
User existingUser, OAuth2UserInfo oAuth2UserInfo) {
existingUser.setName(oAuth2UserInfo.getName());
existingUser.setImageUrl(oAuth2UserInfo.getImageUrl());
return userRepository.save(existingUser);
}
}Custom User Principal
A unified principal that works for both OAuth2 and local (JWT) users:
public class CustomUserPrincipal implements OAuth2User, UserDetails {
private Long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
public CustomUserPrincipal(
Long id, String email, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.email = email;
this.password = password;
this.authorities = authorities;
}
// Constructor for OAuth2 users
public CustomUserPrincipal(User user, Map<String, Object> attributes) {
this.id = user.getId();
this.email = user.getEmail();
this.password = user.getPassword();
this.authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
this.attributes = attributes;
}
// Factory method for local users
public static CustomUserPrincipal create(User user) {
List<GrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
return new CustomUserPrincipal(
user.getId(), user.getEmail(),
user.getPassword(), authorities);
}
// UserDetails methods
@Override
public String getUsername() { return email; }
@Override
public String getPassword() { return password; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
// OAuth2User methods
@Override
public Map<String, Object> getAttributes() { return attributes; }
@Override
public String getName() { return String.valueOf(id); }
public Long getId() { return id; }
}5. User Registration & Account Linking
Handling Different Scenarios
When a user signs in via OAuth2, several scenarios can occur:
Account Linking Strategy
The basic implementation above rejects login if the email exists with a different provider. For a better user experience, you can allow account linking:
@Entity
@Table(name = "user_social_connections")
@Getter
@Setter
@NoArgsConstructor
public class SocialConnection {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider;
@Column(nullable = false)
private String providerId;
private String providerEmail;
@Column(nullable = false)
private LocalDateTime connectedAt = LocalDateTime.now();
}@Repository
public interface SocialConnectionRepository
extends JpaRepository<SocialConnection, Long> {
Optional<SocialConnection> findByProviderAndProviderId(
AuthProvider provider, String providerId);
List<SocialConnection> findByUser(User user);
boolean existsByUserAndProvider(User user, AuthProvider provider);
}Updated user service with account linking:
private OAuth2User processOAuth2User(
OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String registrationId = userRequest.getClientRegistration()
.getRegistrationId();
AuthProvider provider = AuthProvider.valueOf(
registrationId.toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory
.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes());
if (userInfo.getEmail() == null || userInfo.getEmail().isEmpty()) {
throw new OAuth2AuthenticationProcessingException(
"Email not found from OAuth2 provider");
}
// Check if this social connection already exists
Optional<SocialConnection> connectionOpt = socialConnectionRepository
.findByProviderAndProviderId(provider, userInfo.getId());
User user;
if (connectionOpt.isPresent()) {
// Returning user — update info
user = connectionOpt.get().getUser();
user.setName(userInfo.getName());
user.setImageUrl(userInfo.getImageUrl());
userRepository.save(user);
} else {
// Check if user exists by email
Optional<User> userOpt = userRepository
.findByEmail(userInfo.getEmail());
if (userOpt.isPresent()) {
// Link new provider to existing account
user = userOpt.get();
createSocialConnection(user, provider, userInfo);
} else {
// Brand new user
user = registerNewUser(provider, userInfo);
createSocialConnection(user, provider, userInfo);
}
}
return new CustomUserPrincipal(user, oAuth2User.getAttributes());
}
private void createSocialConnection(
User user, AuthProvider provider, OAuth2UserInfo userInfo) {
SocialConnection connection = new SocialConnection();
connection.setUser(user);
connection.setProvider(provider);
connection.setProviderId(userInfo.getId());
connection.setProviderEmail(userInfo.getEmail());
socialConnectionRepository.save(connection);
}GitHub Email Edge Case
GitHub users can have their email set to private. In that case, the email field in the user attributes is null. You need to make an additional API call:
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
// ... other fields
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// GitHub email workaround
String registrationId = userRequest.getClientRegistration()
.getRegistrationId();
Map<String, Object> attributes =
new HashMap<>(oAuth2User.getAttributes());
if ("github".equals(registrationId)
&& attributes.get("email") == null) {
String email = fetchGitHubEmail(userRequest);
attributes.put("email", email);
}
try {
return processOAuth2User(userRequest,
new DefaultOAuth2User(
oAuth2User.getAuthorities(),
attributes,
"id"));
} catch (Exception ex) {
throw new OAuth2AuthenticationProcessingException(
ex.getMessage(), ex);
}
}
private String fetchGitHubEmail(OAuth2UserRequest userRequest) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(
userRequest.getAccessToken().getTokenValue());
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<List<Map<String, Object>>> response =
restTemplate.exchange(
"https://api.github.com/user/emails",
HttpMethod.GET,
entity,
new ParameterizedTypeReference<>() {});
return response.getBody().stream()
.filter(email -> Boolean.TRUE.equals(email.get("primary")))
.map(email -> (String) email.get("email"))
.findFirst()
.orElseThrow(() ->
new OAuth2AuthenticationProcessingException(
"Primary email not found from GitHub"));
}
}6. Combining OAuth2 with JWT
After a successful OAuth2 login, we issue a JWT token so the frontend can authenticate subsequent API requests.
JWT Token Provider
Reuse or adapt the JWT utility from the JWT Authentication guide:
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration}")
private long jwtExpirationMs;
public String generateToken(CustomUserPrincipal userPrincipal) {
return Jwts.builder()
.subject(String.valueOf(userPrincipal.getId()))
.issuedAt(new Date())
.expiration(new Date(
System.currentTimeMillis() + jwtExpirationMs))
.signWith(getSigningKey())
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
}
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
return Keys.hmacShaKeyFor(keyBytes);
}
}OAuth2 Authentication Success Handler
This is the key component that bridges OAuth2 login and JWT:
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider tokenProvider;
@Value("${app.oauth2.authorized-redirect-uri}")
private String authorizedRedirectUri;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
CustomUserPrincipal userPrincipal =
(CustomUserPrincipal) authentication.getPrincipal();
String token = tokenProvider.generateToken(userPrincipal);
// Redirect to frontend with the JWT token
String targetUrl = UriComponentsBuilder
.fromUriString(authorizedRedirectUri)
.queryParam("token", token)
.build()
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}OAuth2 Authentication Failure Handler
Handle errors gracefully:
@Component
public class OAuth2AuthenticationFailureHandler
extends SimpleUrlAuthenticationFailureHandler {
@Value("${app.oauth2.authorized-redirect-uri}")
private String authorizedRedirectUri;
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
String targetUrl = UriComponentsBuilder
.fromUriString(authorizedRedirectUri)
.queryParam("error", exception.getLocalizedMessage())
.build()
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}JWT Authentication Filter
The same filter from the JWT guide — validates JWT on every request:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String jwt = getJwtFromRequest(request);
if (jwt != null && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails =
userDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null,
userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}7. Security Configuration
Bring everything together in the security configuration:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler;
private final OAuth2AuthenticationFailureHandler oAuth2FailureHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
http
// Disable CSRF for stateless JWT APIs
.csrf(csrf -> csrf.disable())
// Session management — stateless for JWT
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Authorization rules
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/oauth2/**",
"/login/oauth2/**"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// OAuth2 login configuration
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
)
// Exception handling
.exceptionHandling(ex -> ex
.authenticationEntryPoint(
(request, response, authException) -> {
response.setStatus(
HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\": \"Unauthorized\"}");
})
)
// JWT filter before Spring Security's filter
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}Complete Authentication Flow
8. Local Login Support
To support both OAuth2 and traditional email/password login side by side:
Auth Controller
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider tokenProvider;
@PostMapping("/signup")
public ResponseEntity<?> registerUser(
@Valid @RequestBody SignUpRequest signUpRequest) {
if (userRepository.existsByEmail(signUpRequest.getEmail())) {
return ResponseEntity.badRequest()
.body(Map.of("error",
"Email address already in use"));
}
User user = new User();
user.setName(signUpRequest.getName());
user.setEmail(signUpRequest.getEmail());
user.setPassword(passwordEncoder.encode(
signUpRequest.getPassword()));
user.setProvider(AuthProvider.LOCAL);
userRepository.save(user);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message",
"User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(
@Valid @RequestBody LoginRequest loginRequest) {
Authentication authentication =
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword()));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
CustomUserPrincipal userPrincipal =
(CustomUserPrincipal) authentication.getPrincipal();
String token = tokenProvider.generateToken(userPrincipal);
return ResponseEntity.ok(Map.of(
"token", token,
"tokenType", "Bearer"
));
}
}Request DTOs
@Getter
@Setter
public class SignUpRequest {
@NotBlank
private String name;
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 8, max = 40)
private String password;
}
@Getter
@Setter
public class LoginRequest {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
}Custom User Details Service (for local login)
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found with email: " + email));
return CustomUserPrincipal.create(user);
}
public UserDetails loadUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found with id: " + id));
return CustomUserPrincipal.create(user);
}
}9. Frontend Integration
OAuth2 Login from a SPA
Your frontend (React, Next.js, Vue, etc.) initiates the OAuth2 flow by redirecting to your backend:
// React / Next.js example
const API_BASE_URL = 'http://localhost:8080';
function LoginPage() {
const handleGoogleLogin = () => {
window.location.href =
`${API_BASE_URL}/oauth2/authorization/google`;
};
const handleGitHubLogin = () => {
window.location.href =
`${API_BASE_URL}/oauth2/authorization/github`;
};
return (
<div>
<h1>Sign In</h1>
<button onClick={handleGoogleLogin}>
Sign in with Google
</button>
<button onClick={handleGitHubLogin}>
Sign in with GitHub
</button>
{/* Local login form */}
<form onSubmit={handleLocalLogin}>
<input type="email" placeholder="Email" />
<input type="password" placeholder="Password" />
<button type="submit">Sign in</button>
</form>
</div>
);
}Handling the Redirect Callback
After OAuth2 login, the backend redirects to your frontend with a JWT token in the query string:
// pages/oauth2/redirect.tsx (or equivalent)
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function OAuth2RedirectPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const token = searchParams.get('token');
const error = searchParams.get('error');
if (token) {
// Store the JWT token
localStorage.setItem('accessToken', token);
router.push('/dashboard');
} else if (error) {
console.error('OAuth2 error:', error);
router.push('/login?error=' + encodeURIComponent(error));
}
}, [searchParams, router]);
return <div>Processing login...</div>;
}
export default OAuth2RedirectPage;Using the JWT Token
Once you have the JWT, include it in all API requests:
// lib/api.ts
const api = {
async fetch(url: string, options: RequestInit = {}) {
const token = localStorage.getItem('accessToken');
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
if (response.status === 401) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
return response;
},
get(url: string) {
return this.fetch(url);
},
post(url: string, data: unknown) {
return this.fetch(url, {
method: 'POST',
body: JSON.stringify(data),
});
},
};
export default api;10. Protecting API Endpoints
Getting the Current User
Create a @CurrentUser annotation for convenience:
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal
public @interface CurrentUser {
}Use it in controllers:
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserRepository userRepository;
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser(
@CurrentUser CustomUserPrincipal currentUser) {
User user = userRepository.findById(currentUser.getId())
.orElseThrow(() -> new ResourceNotFoundException(
"User", "id", currentUser.getId()));
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"name", user.getName(),
"email", user.getEmail(),
"imageUrl", user.getImageUrl(),
"provider", user.getProvider()
));
}
@GetMapping("/me/connections")
public ResponseEntity<?> getSocialConnections(
@CurrentUser CustomUserPrincipal currentUser) {
User user = userRepository.findById(currentUser.getId())
.orElseThrow(() -> new ResourceNotFoundException(
"User", "id", currentUser.getId()));
List<SocialConnection> connections =
socialConnectionRepository.findByUser(user);
return ResponseEntity.ok(connections.stream()
.map(conn -> Map.of(
"provider", conn.getProvider(),
"email", conn.getProviderEmail(),
"connectedAt", conn.getConnectedAt()
))
.toList());
}
}Method-Level Security
Use @PreAuthorize for fine-grained access control — works the same regardless of whether the user logged in via OAuth2 or local credentials:
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public ResponseEntity<List<User>> getAllUsers() {
return ResponseEntity.ok(userRepository.findAll());
}
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/users/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}11. Testing OAuth2
Unit Testing with Mocked OAuth2
Spring Security Test provides @WithMockUser and OAuth2 test utilities:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserRepository userRepository;
@Test
@WithMockUser(username = "1", roles = "USER")
void testGetCurrentUser() throws Exception {
User user = new User();
user.setId(1L);
user.setName("John Doe");
user.setEmail("john@example.com");
user.setProvider(AuthProvider.GOOGLE);
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.provider").value("GOOGLE"));
}
@Test
void testUnauthenticatedAccess() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
}Testing OAuth2 Login Flow
Use SecurityMockMvcRequestPostProcessors.oauth2Login():
@SpringBootTest
@AutoConfigureMockMvc
class OAuth2LoginTest {
@Autowired
private MockMvc mockMvc;
@Test
void testOAuth2LoginCreatesUser() throws Exception {
mockMvc.perform(get("/api/users/me")
.with(oauth2Login()
.oauth2User(new DefaultOAuth2User(
List.of(
new SimpleGrantedAuthority("ROLE_USER")),
Map.of(
"sub", "123456",
"name", "Test User",
"email", "test@gmail.com",
"picture", "https://example.com/photo.jpg"
),
"sub"
))))
.andExpect(status().isOk());
}
}Integration Test with Full Flow
@SpringBootTest
@AutoConfigureMockMvc
class AuthIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtTokenProvider tokenProvider;
@Test
void testLocalSignupAndLogin() throws Exception {
// 1. Sign up
mockMvc.perform(post("/api/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Jane Doe",
"email": "jane@example.com",
"password": "password123"
}
"""))
.andExpect(status().isCreated());
// 2. Login and get token
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"email": "jane@example.com",
"password": "password123"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").exists())
.andReturn();
String token = JsonPath.read(
result.getResponse().getContentAsString(), "$.token");
// 3. Access protected endpoint with JWT
mockMvc.perform(get("/api/users/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("jane@example.com"))
.andExpect(jsonPath("$.provider").value("LOCAL"));
}
@AfterEach
void cleanup() {
userRepository.deleteAll();
}
}12. Production Considerations
Security Checklist
| Concern | Solution |
|---|---|
| Credential storage | Use environment variables or a secrets manager (Vault, AWS Secrets Manager) |
| HTTPS required | OAuth2 providers require HTTPS for redirect URIs in production |
| State parameter | Spring Security handles CSRF protection via the state parameter automatically |
| Token in URL | The JWT in the redirect URL is short-lived; the frontend stores it and removes it from the URL |
| Rate limiting | Limit login attempts to prevent brute-force attacks |
| Token expiration | Set short JWT expiration (15-60 min) with refresh tokens |
| Redirect URI validation | Only allow whitelisted redirect URIs |
Redirect URI Validation
Prevent open redirect attacks by validating the redirect URI:
@Component
public class OAuth2AuthenticationSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
@Value("${app.oauth2.authorized-redirect-uri}")
private String authorizedRedirectUri;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
String targetUrl = determineTargetUrl(request, authentication);
if (response.isCommitted()) {
logger.debug("Response already committed.");
return;
}
getRedirectStrategy().sendRedirect(
request, response, targetUrl);
}
private String determineTargetUrl(
HttpServletRequest request,
Authentication authentication) {
CustomUserPrincipal userPrincipal =
(CustomUserPrincipal) authentication.getPrincipal();
String token = tokenProvider.generateToken(userPrincipal);
return UriComponentsBuilder
.fromUriString(authorizedRedirectUri)
.queryParam("token", token)
.build()
.toUriString();
}
}Production application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: email, profile
redirect-uri: "https://yourdomain.com/login/oauth2/code/google"
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: user:email, read:user
redirect-uri: "https://yourdomain.com/login/oauth2/code/github"
app:
jwt:
secret: ${JWT_SECRET}
expiration: 3600000 # 1 hour in production
oauth2:
authorized-redirect-uri: https://yourdomain.com/oauth2/redirectAdding More Providers
The architecture makes it easy to add new providers. For example, to add Facebook:
- Register your app at Facebook Developers
- Add to
application.yml:
spring:
security:
oauth2:
client:
registration:
facebook:
client-id: ${FACEBOOK_CLIENT_ID}
client-secret: ${FACEBOOK_CLIENT_SECRET}
scope: email, public_profile- Create
FacebookOAuth2UserInfo:
public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
public FacebookOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
Map<String, Object> pictureObj =
(Map<String, Object>) attributes.get("picture");
if (pictureObj != null) {
Map<String, Object> data =
(Map<String, Object>) pictureObj.get("data");
if (data != null) {
return (String) data.get("url");
}
}
return null;
}
}- Add to
OAuth2UserInfoFactoryandAuthProviderenum
Common Issues
Issue 1: Redirect URI Mismatch
Error: redirect_uri_mismatch from Google or GitHub
Solution: The redirect URI in your OAuth provider console must exactly match the one Spring Boot uses:
http://localhost:8080/login/oauth2/code/google (development)
https://yourdomain.com/login/oauth2/code/google (production)Check for trailing slashes, http vs https, and port numbers.
Issue 2: GitHub Email is Null
Problem: GitHub users with private emails return null for the email field.
Solution: Request the user:email scope and fetch emails via the GitHub API (shown in Section 5).
Issue 3: CORS with OAuth2 Redirects
Problem: CORS errors when the frontend redirects to the backend OAuth2 endpoint.
Solution: The frontend should use window.location.href (full page redirect), not fetch(). OAuth2 login requires a browser redirect, not an AJAX call.
// CORRECT — full page redirect
window.location.href = '/oauth2/authorization/google';
// WRONG — will cause CORS issues
fetch('/oauth2/authorization/google');Issue 4: Session Management Conflicts
Problem: OAuth2 flow needs a session to store the authorization request, but your API is stateless.
Solution: Use HttpSessionOAuth2AuthorizationRequestRepository temporarily during OAuth2 flow, but keep the API stateless with JWT:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(auth -> auth
.authorizationRequestRepository(
new HttpSessionOAuth2AuthorizationRequestRepository()))
// ...
);
return http.build();
}Note: For fully stateless OAuth2, consider using cookie-based authorization request storage instead.
Summary and Key Takeaways
✅ OAuth2 Authorization Code flow is the standard for social login — it keeps tokens server-side
✅ Spring Boot auto-configures Google and GitHub — just add credentials in application.yml
✅ CustomOAuth2UserService maps provider data to your user model and handles registration
✅ Account linking lets users connect multiple providers to one account
✅ Combine OAuth2 with JWT — OAuth2 authenticates, then your app issues a JWT for API access
✅ OAuth2SuccessHandler bridges the gap by issuing a JWT after successful social login
✅ Frontend integration uses full-page redirects, not AJAX, for the OAuth2 flow
✅ @CurrentUser annotation provides a clean way to access the authenticated user in controllers
✅ Test with oauth2Login() mock from Spring Security Test for integration testing
✅ Production security requires HTTPS, redirect URI validation, and proper credential management
What's Next?
Now that you've added social login to your Spring Boot application, continue building:
Continue the Spring Boot Series
- Advanced Testing: Learn contract testing and performance testing to ensure your OAuth2 flow is production-ready
- Docker & Kubernetes Deployment: Containerize your app with proper secrets management for OAuth2 credentials
- Microservices with Spring Cloud: Implement centralized OAuth2 authentication across multiple services
Related Spring Boot Posts
- JWT Authentication & Authorization — the foundation this post builds on
- REST API Best Practices — design patterns for your secured endpoints
- API Documentation with OpenAPI — document your OAuth2-secured API
Foundation Posts
- HTTP Protocol Complete Guide — understand the redirects and headers in OAuth2 flows
- Server-Client Architecture — the client-server model behind OAuth2
Part of the Spring Boot Learning Roadmap series
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.