Back to blog

OAuth2 & Social Login in Spring Boot

javaspring-bootoauth2securityauthenticationbackend
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


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

RoleDescriptionExample
Resource OwnerThe user who owns the dataThe person clicking "Sign in with Google"
ClientYour application requesting accessYour Spring Boot app
Authorization ServerIssues tokens after authenticatingGoogle's OAuth server
Resource ServerHosts the protected user dataGoogle'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

AspectOAuth2 (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 registrationSimpler — no password to manageYou handle everything
Trust modelTrust the providerTrust your own server
Best forConsumer apps, reducing frictionInternal 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

  1. Go to Google Cloud Console
  2. Create a new project (or select existing)
  3. Navigate to APIs & Services → Credentials
  4. Click Create Credentials → OAuth client ID
  5. Select Web application
  6. Add Authorized redirect URIs:
    http://localhost:8080/login/oauth2/code/google
  7. 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

  1. Go to GitHub Developer Settings
  2. Click New OAuth App
  3. Fill in:
    • Application name: Your App Name
    • Homepage URL: http://localhost:8080
    • Authorization callback URL: http://localhost:8080/login/oauth2/code/github
  4. Click Register application
  5. 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-secret

3. 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/redirect

Note: Spring Boot auto-configures Google and GitHub providers. You only need client-id, client-secret, and scope. 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:

  1. Creates login endpoints at /oauth2/authorization/google and /oauth2/authorization/github
  2. Handles the OAuth2 callback at /login/oauth2/code/google and /login/oauth2/code/github
  3. Exchanges the authorization code for an access token
  4. Fetches user info from the provider
  5. Creates an OAuth2AuthenticationToken in 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

ConcernSolution
Credential storageUse environment variables or a secrets manager (Vault, AWS Secrets Manager)
HTTPS requiredOAuth2 providers require HTTPS for redirect URIs in production
State parameterSpring Security handles CSRF protection via the state parameter automatically
Token in URLThe JWT in the redirect URL is short-lived; the frontend stores it and removes it from the URL
Rate limitingLimit login attempts to prevent brute-force attacks
Token expirationSet short JWT expiration (15-60 min) with refresh tokens
Redirect URI validationOnly 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/redirect

Adding More Providers

The architecture makes it easy to add new providers. For example, to add Facebook:

  1. Register your app at Facebook Developers
  2. Add to application.yml:
spring:
  security:
    oauth2:
      client:
        registration:
          facebook:
            client-id: ${FACEBOOK_CLIENT_ID}
            client-secret: ${FACEBOOK_CLIENT_SECRET}
            scope: email, public_profile
  1. 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;
    }
}
  1. Add to OAuth2UserInfoFactory and AuthProvider enum

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

Foundation Posts


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.