OAuth2 Login: Sign In with Google, GitHub, and Custom Providers

What OAuth2 Login Does

OAuth2LoginConfigurer implements the Authorization Code flow for user authentication. When a user clicks “Sign in with Google”:

  1. Spring redirects to Google’s /authorize endpoint
  2. User authenticates on Google and grants consent
  3. Google redirects back to your app with a code
  4. Spring exchanges the code for tokens (back channel)
  5. Spring fetches the user profile from /userinfo
  6. Spring creates an OAuth2User and stores it in the SecurityContext

Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

Configuring Google and GitHub

# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user, user:email
          okta:
            client-id: ${OKTA_CLIENT_ID}
            client-secret: ${OKTA_CLIENT_SECRET}
            scope: openid, profile, email
        provider:
          okta:
            issuer-uri: https://dev-XXXXX.okta.com/oauth2/default
          # Google and GitHub are pre-configured — no provider block needed

Spring Boot has built-in provider metadata for Google, GitHub, Facebook, Okta, and a few others. For Google/GitHub, you only need registration.


Security Configuration

@Configuration
@EnableWebSecurity
public class OAuth2SecurityConfig {

    private final CustomOAuth2UserService oAuth2UserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**", "/error").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/login")                          // custom login page
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(oAuth2UserService)           // custom user service
                )
                .successHandler(oAuth2AuthSuccessHandler())   // after login
                .failureHandler(oAuth2AuthFailureHandler())   // on error
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
                .deleteCookies("JSESSIONID")
            );
        return http.build();
    }
}

The Login Page

@Controller
public class LoginController {

    @GetMapping("/login")
    public String loginPage() {
        return "login"; // login.html
    }
}
<!-- login.html — links to Spring Security's OAuth2 authorization endpoints -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Login</title></head>
<body>
  <h1>Sign In</h1>

  <!-- Spring Security auto-generates these URLs: /oauth2/authorization/{registrationId} -->
  <a href="/oauth2/authorization/google">
    <img src="/icons/google.svg" alt="Sign in with Google"/>
    Sign in with Google
  </a>

  <a href="/oauth2/authorization/github">
    <img src="/icons/github.svg" alt="Sign in with GitHub"/>
    Sign in with GitHub
  </a>

  <a href="/oauth2/authorization/okta">
    Sign in with Okta
  </a>
</body>
</html>

/oauth2/authorization/{registrationId} is a Spring Security endpoint that starts the OAuth2 flow for that provider. You don’t implement it.


OAuth2User: What You Get After Login

After the OAuth2 flow completes, Spring creates an OAuth2User:

@GetMapping("/profile")
public String profile(@AuthenticationPrincipal OAuth2User oauth2User, Model model) {
    // For Google (OIDC) — attributes from the id_token + userinfo
    String name     = oauth2User.getAttribute("name");
    String email    = oauth2User.getAttribute("email");
    String picture  = oauth2User.getAttribute("picture");
    String googleId = oauth2User.getAttribute("sub");

    // For GitHub
    String login   = oauth2User.getAttribute("login");   // GitHub username
    String htmlUrl = oauth2User.getAttribute("html_url");

    // Authorities — by default a single authority: ROLE_USER (from "SCOPE_openid" etc.)
    Collection<? extends GrantedAuthority> authorities = oauth2User.getAuthorities();

    model.addAttribute("user", oauth2User);
    return "profile";
}

For OIDC providers (Google, Okta), you get OidcUser (extends OAuth2User) with additional methods:

@GetMapping("/profile")
public String profile(@AuthenticationPrincipal OidcUser oidcUser) {
    OidcIdToken idToken = oidcUser.getIdToken();
    String subject = oidcUser.getSubject();       // unique user ID (never changes)
    String email   = oidcUser.getEmail();
    String name    = oidcUser.getFullName();
    Instant expiry = idToken.getExpiresAt();
    return "profile";
}

Custom OAuth2UserService: Persisting Users to Database

By default, Spring Security creates an in-memory OAuth2User on each login — no persistence. For real applications, you want to:

  1. Look up whether this OAuth2 user has registered before (by provider + sub ID)
  2. Create a local user record on first login
  3. Return a custom UserDetails / OAuth2User with your app’s roles
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = super.loadUser(userRequest); // fetch from provider

        String provider = userRequest.getClientRegistration().getRegistrationId(); // "google", "github"
        Map<String, Object> attributes = oauth2User.getAttributes();

        // Extract identity — different providers use different attribute names
        String providerId = extractProviderId(provider, attributes);
        String email      = extractEmail(provider, attributes, oauth2User);
        String name       = extractName(provider, attributes);
        String pictureUrl = extractPicture(provider, attributes);

        // Find or create the local user
        User user = userRepository.findByProviderAndProviderId(provider, providerId)
            .orElseGet(() -> createNewUser(provider, providerId, email, name, pictureUrl));

        // Update on each login (name/picture may change)
        user.setName(name);
        user.setPictureUrl(pictureUrl);
        if (user.getEmail() == null && email != null) {
            user.setEmail(email);
        }
        userRepository.save(user);

        return new AppOAuth2User(user, attributes, oauth2User.getAuthorities());
    }

    private User createNewUser(String provider, String providerId,
                               String email, String name, String pictureUrl) {
        User user = new User();
        user.setProvider(provider);
        user.setProviderId(providerId);
        user.setEmail(email);
        user.setName(name);
        user.setPictureUrl(pictureUrl);
        user.setRoles(Set.of(Role.USER));  // default role
        user.setEmailVerified(true);        // trusted from OAuth provider
        return userRepository.save(user);
    }

    private String extractProviderId(String provider, Map<String, Object> attrs) {
        return switch (provider) {
            case "google", "okta" -> String.valueOf(attrs.get("sub"));
            case "github"         -> String.valueOf(attrs.get("id"));
            case "facebook"       -> String.valueOf(attrs.get("id"));
            default -> throw new OAuth2AuthenticationException("Unknown provider: " + provider);
        };
    }

    private String extractEmail(String provider, Map<String, Object> attrs, OAuth2User user) {
        Object email = attrs.get("email");
        return email != null ? email.toString() : null;
    }

    private String extractName(String provider, Map<String, Object> attrs) {
        return switch (provider) {
            case "google", "okta" -> String.valueOf(attrs.getOrDefault("name", ""));
            case "github"         -> String.valueOf(attrs.getOrDefault("name",
                                        attrs.getOrDefault("login", "")));
            default -> "";
        };
    }

    private String extractPicture(String provider, Map<String, Object> attrs) {
        return switch (provider) {
            case "google", "okta" -> String.valueOf(attrs.getOrDefault("picture", ""));
            case "github"         -> String.valueOf(attrs.getOrDefault("avatar_url", ""));
            default -> "";
        };
    }
}

Custom OAuth2User

public class AppOAuth2User implements OAuth2User, UserDetails {

    private final User user;
    private final Map<String, Object> attributes;
    private final Collection<? extends GrantedAuthority> oauthAuthorities;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // Use app roles, not OAuth2 scopes
        return user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
            .toList();
    }

    @Override public String getName() { return user.getProviderId(); }
    @Override public String getUsername() { return user.getEmail(); }
    @Override public String getPassword() { return null; } // no password for OAuth2 users

    public Long getId() { return user.getId(); }
    public String getEmail() { return user.getEmail(); }
    public String getDisplayName() { return user.getName(); }
}

Success Handler: Post-Login Redirect

@Component
public class OAuth2AuthSuccessHandler implements AuthenticationSuccessHandler {

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(
        HttpServletRequest request,
        HttpServletResponse response,
        Authentication authentication
    ) throws IOException {
        AppOAuth2User user = (AppOAuth2User) authentication.getPrincipal();

        // Check if user needs to complete profile (e.g., email missing from GitHub)
        if (user.getEmail() == null) {
            redirectStrategy.sendRedirect(request, response, "/profile/complete");
            return;
        }

        // Check for saved request (user was redirected from a protected page)
        HttpSession session = request.getSession(false);
        if (session != null) {
            SavedRequest savedRequest = (SavedRequest) session.getAttribute(
                HttpSessionRequestCache.class.getName() + "_SAVED_REQUEST"
            );
            if (savedRequest != null) {
                redirectStrategy.sendRedirect(request, response, savedRequest.getRedirectUrl());
                return;
            }
        }

        redirectStrategy.sendRedirect(request, response, "/dashboard");
    }
}

Adding Custom Providers (Keycloak Example)

For providers not in Spring’s built-in list:

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-app
            client-secret: ${KEYCLOAK_SECRET}
            authorization-grant-type: authorization_code
            scope: openid, profile, email
            redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
        provider:
          keycloak:
            issuer-uri: https://keycloak.example.com/realms/myrealm
            # Spring fetches endpoints from: issuer-uri/.well-known/openid-configuration

OIDC User Service for ID Token Claims

For OIDC providers (Google, Okta, Keycloak), use OidcUserService instead of DefaultOAuth2UserService:

@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(userRequest);

        String provider   = userRequest.getClientRegistration().getRegistrationId();
        String providerId = oidcUser.getSubject(); // 'sub' claim — stable identifier

        User user = userRepository.findByProviderAndProviderId(provider, providerId)
            .orElseGet(() -> createUser(oidcUser, provider));

        return new AppOidcUser(user, oidcUser.getIdToken(), oidcUser.getUserInfo());
    }
}
// Register both services
http.oauth2Login(oauth2 -> oauth2
    .userInfoEndpoint(userInfo -> userInfo
        .userService(customOAuth2UserService)    // for non-OIDC (GitHub)
        .oidcUserService(customOidcUserService)  // for OIDC (Google, Okta)
    )
);

GitHub: Fetching Private Email

GitHub does not include the email in the userinfo response if the user’s email is private. You must make a separate API call:

@Service
@RequiredArgsConstructor
public class GitHubEmailService {

    private final RestTemplate restTemplate;

    public String fetchPrimaryEmail(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);
        headers.set("Accept", "application/vnd.github+json");
        HttpEntity<Void> entity = new HttpEntity<>(headers);

        ResponseEntity<List<Map<String, Object>>> response = restTemplate.exchange(
            "https://api.github.com/user/emails",
            HttpMethod.GET,
            entity,
            new ParameterizedTypeReference<>() {}
        );

        return Optional.ofNullable(response.getBody())
            .flatMap(emails -> emails.stream()
                .filter(e -> Boolean.TRUE.equals(e.get("primary"))
                          && Boolean.TRUE.equals(e.get("verified")))
                .map(e -> (String) e.get("email"))
                .findFirst())
            .orElse(null);
    }
}
// In CustomOAuth2UserService.loadUser(), after super.loadUser():
if ("github".equals(provider)) {
    String accessToken = userRequest.getAccessToken().getTokenValue();
    email = gitHubEmailService.fetchPrimaryEmail(accessToken);
}

Summary

  • Add spring-boot-starter-oauth2-client and configure provider credentials in application.yml.
  • Spring auto-provides registrationId-specific URLs: /oauth2/authorization/{id} starts the flow, /login/oauth2/code/{id} is the callback.
  • Implement DefaultOAuth2UserService (for non-OIDC) or OidcUserService (for OIDC) to persist OAuth2 users to your database on first login.
  • Extract the stable identifier from sub (OIDC) or id (GitHub/Facebook) — not from email (users can change it).
  • Use a custom AuthenticationSuccessHandler for post-login redirects and profile completion flows.

Next: Article 14 covers the OAuth2 Resource Server — protecting your own REST API with JWT or opaque tokens issued by an authorization server.