Spring BootGitHub Login
Spring Boot

GitHub Login

GitHub login uses OAuth2 (not OIDC) — there is no ID token. Spring Security fetches user information from GitHub's /user and /user/emails API endpoints after the token exchange. This entry covers GitHub OAuth App setup, email retrieval for private email addresses, organisation membership checks, and using the GitHub API with the access token.

GitHub OAuth App Setup

Create a GitHub OAuth App in Developer Settings. The Authorization callback URL must exactly match Spring Security's registered redirect URI. Choose between OAuth App (user-level) and GitHub App (installation-level) based on whether you need organisation-level permissions.
yaml
# ── GitHub OAuth App setup steps ─────────────────────────────────────
# 1. GitHub → Settings → Developer settings → OAuth Apps
#    → New OAuth App
#
# 2. Fill in:
#    Application name:      My Spring Boot App
#    Homepage URL:          https://myapp.com
#    Authorization callback URL:
#      Development: http://localhost:8080/login/oauth2/code/github
#      Production:  https://myapp.com/login/oauth2/code/github
#
# 3. Click Register application
# 4. Note the Client ID
# 5. Generate a new client secret and note it

# ── application.yml ────────────────────────────────────────────────────
spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id:     ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope:
              - read:user   # user profile: login, name, avatar_url, bio
              - user:email  # email addresses (required for private email)
            redirect-uri: >
              {baseUrl}/login/oauth2/code/{registrationId}
            client-name: GitHub

# ── For organisation membership checks, add the org scope ─────────────
#           scope:
#             - read:user
#             - user:email
#             - read:org

GitHub OAuth2UserService

GitHub does not send the email address in the primary user info response when the user's email is set to private. Retrieve it separately from the /user/emails endpoint using the access token. Always fall back to a noreply address so registration never fails due to a missing email.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class GitHubOAuth2UserService extends DefaultOAuth2UserService {

    private final AppUserRepository       userRepo;
    private final SocialAccountRepository socialRepo;
    private final GitHubApiClient         gitHubApi;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        // ── Extract GitHub profile attributes ─────────────────────────
        String githubId   = String.valueOf(
            oAuth2User.getAttribute("id"));     // numeric user ID
        String login      = oAuth2User.getAttribute("login");
        String name       = oAuth2User.getAttribute("name");
        String avatarUrl  = oAuth2User.getAttribute("avatar_url");
        String bio        = oAuth2User.getAttribute("bio");

        // ── Resolve email (may be null for private email settings) ─────
        String email = oAuth2User.getAttribute("email");
        if (email == null || email.isBlank()) {
            // Fetch from /user/emails endpoint
            String accessToken = userRequest.getAccessToken().getTokenValue();
            email = gitHubApi.fetchPrimaryEmail(accessToken)
                .orElse(login + "@users.noreply.github.com");
        }

        final String resolvedEmail = email;

        // ── Find or create application user ───────────────────────────
        AppUser appUser = socialRepo
            .findByProviderAndProviderId("github", githubId)
            .map(SocialAccount::getUser)
            .orElseGet(() ->
                registerOrLink(githubId, resolvedEmail, name,
                               login, avatarUrl));

        // ── Update profile on each login ──────────────────────────────
        boolean updated = false;
        if (avatarUrl != null &&
                !avatarUrl.equals(appUser.getPictureUrl())) {
            appUser.setPictureUrl(avatarUrl);
            updated = true;
        }
        if (name != null && !name.equals(appUser.getName())) {
            appUser.setName(name);
            updated = true;
        }
        if (updated) userRepo.save(appUser);

        log.info("GitHub login: {} (id={})", login, githubId);
        return appUser;
    }

    private AppUser registerOrLink(String githubId, String email,
            String name, String login, String avatarUrl) {

        AppUser user = userRepo.findByEmail(email)
            .orElseGet(() -> {
                AppUser u = new AppUser();
                u.setEmail(email);
                u.setName(name != null ? name : login);
                u.setPictureUrl(avatarUrl);
                u.setPassword("");
                u.setEnabled(true);
                u.setEmailVerified(true);
                u.setRoles(Set.of("USER"));
                return userRepo.save(u);
            });

        SocialAccount social = new SocialAccount();
        social.setProvider("github");
        social.setProviderId(githubId);
        social.setUser(user);
        social.setLinkedAt(LocalDateTime.now());
        socialRepo.save(social);
        return user;
    }
}

GitHub API Client

Use the GitHub access token to call GitHub's REST API for data not available in the userinfo response — private email addresses, organisation membership, repository access, and team membership. Spring's RestClient makes the API calls straightforward.
Java
@Component
@Slf4j
public class GitHubApiClient {

    private final RestClient restClient;

    public GitHubApiClient(RestClient.Builder builder) {
        this.restClient = builder
            .baseUrl("https://api.github.com")
            .defaultHeader(HttpHeaders.ACCEPT,
                "application/vnd.github+json")
            .defaultHeader("X-GitHub-Api-Version", "2022-11-28")
            .build();
    }

    // ── Fetch verified primary email ──────────────────────────────────
    public Optional<String> fetchPrimaryEmail(String accessToken) {
        try {
            List<GitHubEmail> emails = restClient.get()
                .uri("/user/emails")
                .header(HttpHeaders.AUTHORIZATION,
                    "Bearer " + accessToken)
                .retrieve()
                .body(new ParameterizedTypeReference<>() {});

            return Optional.ofNullable(emails)
                .flatMap(list -> list.stream()
                    .filter(GitHubEmail::primary)
                    .filter(GitHubEmail::verified)
                    .map(GitHubEmail::email)
                    .findFirst());
        } catch (Exception ex) {
            log.warn("Failed to fetch GitHub emails: {}",
                ex.getMessage());
            return Optional.empty();
        }
    }

    // ── Check organisation membership ─────────────────────────────────
    public boolean isMemberOfOrganisation(String accessToken,
                                           String org,
                                           String username) {
        try {
            restClient.get()
                .uri("/orgs/{org}/members/{username}", org, username)
                .header(HttpHeaders.AUTHORIZATION,
                    "Bearer " + accessToken)
                .retrieve()
                .toBodilessEntity();
            return true;   // 204 No Content → is a member
        } catch (HttpClientErrorException.NotFound ex) {
            return false;
        }
    }

    // ── Fetch user repositories ───────────────────────────────────────
    public List<GitHubRepo> fetchRepositories(String accessToken) {
        return restClient.get()
            .uri("/user/repos?sort=updated&per_page=30")
            .header(HttpHeaders.AUTHORIZATION,
                "Bearer " + accessToken)
            .retrieve()
            .body(new ParameterizedTypeReference<>() {});
    }

    // ── Response records ──────────────────────────────────────────────
    public record GitHubEmail(
        String  email,
        boolean primary,
        boolean verified,
        String  visibility
    ) {}

    public record GitHubRepo(
        Long    id,
        String  name,
        String  fullName,
        boolean privateRepo,
        String  htmlUrl
    ) {}
}

Organisation-Restricted Login

Restrict login to members of a specific GitHub organisation. Check membership after the OAuth2 flow completes, inside the OAuth2UserService. Users who are not organisation members receive an authentication failure.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class GitHubOrgRestrictedUserService
        extends DefaultOAuth2UserService {

    private final AppUserRepository  userRepo;
    private final GitHubApiClient    gitHubApi;

    @Value("${app.github.required-org:}")
    private String requiredOrg;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String login       = oAuth2User.getAttribute("login");
        String accessToken = userRequest.getAccessToken()
            .getTokenValue();

        // ── Check organisation membership ─────────────────────────────
        if (requiredOrg != null && !requiredOrg.isBlank()) {
            boolean isMember = gitHubApi.isMemberOfOrganisation(
                accessToken, requiredOrg, login);

            if (!isMember) {
                log.warn(
                    "GitHub login denied — {} is not a member of org {}",
                    login, requiredOrg);
                throw new OAuth2AuthenticationException(
                    new OAuth2Error("not_org_member"),
                    "You must be a member of the " + requiredOrg +
                    " organisation to log in.");
            }
        }

        String githubId = String.valueOf(
            oAuth2User.getAttribute("id"));
        String email    = resolveEmail(oAuth2User, accessToken, login);
        String name     = oAuth2User.getAttribute("name");

        return findOrCreate(githubId, email, name, login, oAuth2User);
    }

    private String resolveEmail(OAuth2User user,
                                 String token, String login) {
        String email = user.getAttribute("email");
        if (email == null || email.isBlank()) {
            email = gitHubApi.fetchPrimaryEmail(token)
                .orElse(login + "@users.noreply.github.com");
        }
        return email;
    }

    private AppUser findOrCreate(String githubId, String email,
            String name, String login, OAuth2User oAuth2User) {
        return userRepo.findByEmail(email).orElseGet(() -> {
            AppUser user = new AppUser();
            user.setEmail(email);
            user.setName(name != null ? name : login);
            user.setPictureUrl(oAuth2User.getAttribute("avatar_url"));
            user.setPassword("");
            user.setEnabled(true);
            user.setEmailVerified(true);
            user.setRoles(Set.of("USER"));
            return userRepo.save(user);
        });
    }
}

# ── application.yml — set the required organisation ───────────────────
app:
  github:
    required-org: my-company-org   # leave blank to allow anyone