Spring Boot
GitHub Login
Add GitHub login to a Spring Boot 3 application with Spring Security's oauth2Login. GitHub uses plain OAuth2 with no ID token, so the profile and email come from GitHub's REST API. This page covers app registration, wiring a custom user service, reading the verified email, safely linking accounts, restricting login to an organisation, calling the API later, and testing, including the account-takeover and JSON-mapping mistakes that most tutorials contain.
Registering the GitHub OAuth App
In GitHub, open Settings, then Developer settings, then OAuth Apps, and choose New OAuth App. Enter your application's name and homepage, and set the Authorization callback URL to your application's base URL followed by /login/oauth2/code/github, which is the path Spring Security listens on. An OAuth App accepts only one callback URL, so create one app for local development (http://localhost:8080/login/oauth2/code/github) and a separate one for production. After registering, copy the Client ID and generate a client secret; keep both in environment variables, never in the repository. Spring Security already knows GitHub's endpoints and the redirect URI pattern, so the configuration only needs the credentials and scopes. Request the least access you need: read:user for the profile and user:email so the application can read the user's email addresses. Add read:org only if you restrict login to an organisation. GitHub also offers GitHub Apps, which use fine-grained permissions instead of scopes; an OAuth App is the simpler choice for a plain "Sign in with GitHub" button.
yaml
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope:
- read:user # profile: id, login, name, avatar
- user:email # needed to read email addresses, including private ones
# - read:org # add only if you restrict login to an organisation
app:
github:
required-org: # e.g. my-company-org; leave blank to allow any GitHub userSecurity Configuration
This is the wiring that most tutorials leave out. oauth2Login switches on the GitHub flow, and userInfoEndpoint().userService(...) tells Spring Security to use your own user service instead of the default one. The failure handler turns an OAuth2AuthenticationException, such as a user who is not in the required organisation, into a redirect carrying the error code, so the page can show a useful message instead of a generic login error. If the application runs behind a reverse proxy or load balancer, also set server.forward-headers-strategy to framework; otherwise Spring builds the redirect URI with http instead of https and GitHub rejects it with a redirect_uri mismatch.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
GitHubOAuth2UserService gitHubUserService) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth -> oauth
.userInfoEndpoint(userInfo -> userInfo.userService(gitHubUserService))
.failureHandler((request, response, exception) -> {
String code = exception instanceof OAuth2AuthenticationException oae
? oae.getError().getErrorCode()
: "login_failed";
response.sendRedirect("/?login_error=" + URLEncoder.encode(code, StandardCharsets.UTF_8));
}))
.build();
}
}The GitHub API Client
GitHub's user endpoint returns an email only if the user made one public, so the application must ask the /user/emails endpoint for the rest. Use only an address that is both primary and verified; an unverified address proves nothing about who owns it. The organisation check calls /user/memberships/orgs/{org}, which tells you directly whether the signed-in user is an active member. The older /orgs/{org}/members/{username} endpoint answers with a redirect for non-members of the org and is harder to interpret. The check fails closed: a 404 means "not a member", but any other error (403 because the organisation restricts third-party OAuth apps, a 5xx, a timeout) denies login with a distinct error code instead of guessing. One detail that breaks silently: GitHub's JSON uses snake_case (full_name, html_url, private), and Jackson will not map those to camelCase record components unless you say so with @JsonProperty.
Java
@Component
public class GitHubApiClient {
private static final Logger log = LoggerFactory.getLogger(GitHubApiClient.class);
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();
}
/** The user's primary email, only if GitHub has verified it. */
public Optional<String> fetchPrimaryVerifiedEmail(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 (RestClientException ex) {
log.warn("Could not read GitHub emails: {}", ex.getMessage());
return Optional.empty();
}
}
/** True only for an active member of the organisation; any unexpected error denies login. */
public boolean isActiveMember(String accessToken, String org) {
try {
OrgMembership membership = restClient.get()
.uri("/user/memberships/orgs/{org}", org)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.body(OrgMembership.class);
return membership != null && "active".equals(membership.state());
} catch (HttpClientErrorException.NotFound ex) {
return false; // not a member
} catch (RestClientException ex) {
throw new OAuth2AuthenticationException(
new OAuth2Error("org_check_failed"),
"Could not verify organisation membership", ex);
}
}
/** First page of repositories; private ones need the broader 'repo' scope. */
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<>() {});
}
public record GitHubEmail(String email, boolean primary, boolean verified, String visibility) { }
public record OrgMembership(String state, String role) { }
public record GitHubRepo(
Long id,
String name,
@JsonProperty("full_name") String fullName,
@JsonProperty("private") boolean privateRepo,
@JsonProperty("html_url") String htmlUrl) { }
}Loading the GitHub User
The user service runs once per login, after GitHub has issued the access token. It does the remote work first (organisation check, email lookup), then hands the results to a separate transactional service, so no database transaction stays open while waiting for GitHub. Two details deserve attention. Never write String.valueOf(oAuth2User.getAttribute("id")): getAttribute is generic, Java picks the String.valueOf(char[]) overload, and the call fails at runtime with a ClassCastException. Read the attribute into an Object variable first. And identify the user by GitHub's numeric id, never by login name: logins can be changed, and an abandoned name can later be claimed by someone else.
Java
@Service
public class GitHubOAuth2UserService extends DefaultOAuth2UserService {
private final GitHubApiClient gitHubApi;
private final GitHubAccountService accounts;
private final String requiredOrg;
public GitHubOAuth2UserService(GitHubApiClient gitHubApi,
GitHubAccountService accounts,
@Value("${app.github.required-org:}") String requiredOrg) {
this.gitHubApi = gitHubApi;
this.accounts = accounts;
this.requiredOrg = requiredOrg;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User gitHubUser = super.loadUser(userRequest);
String token = userRequest.getAccessToken().getTokenValue();
if (requiredOrg != null && !requiredOrg.isBlank() && !gitHubApi.isActiveMember(token, requiredOrg)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("not_org_member"),
"You must be a member of " + requiredOrg + " to sign in.");
}
Object idAttribute = gitHubUser.getAttribute("id"); // an Integer or Long, not a String
String githubId = String.valueOf(idAttribute);
String login = gitHubUser.getAttribute("login");
String name = gitHubUser.getAttribute("name");
String avatarUrl = gitHubUser.getAttribute("avatar_url");
String verifiedEmail = gitHubApi.fetchPrimaryVerifiedEmail(token).orElse(null);
AppUser appUser = accounts.findOrCreate(githubId, verifiedEmail, name, login, avatarUrl);
Map<String, Object> attributes = new HashMap<>(gitHubUser.getAttributes());
attributes.put("appUserId", appUser.getId());
Set<GrantedAuthority> authorities = appUser.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
String nameAttributeKey = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // "id" for GitHub
return new DefaultOAuth2User(authorities, attributes, nameAttributeKey);
}
}Finding or Creating the Local Account
Linking a GitHub login to an existing local account by email is where most social-login implementations become vulnerable to account takeover. Follow three rules. Link by email only when GitHub reports that email as primary and verified. Never link to a local account whose own email is unverified, because an attacker could have pre-registered the victim's address with a password they control. And never treat a made-up fallback address as verified. When GitHub gives no verified email, the code stores the official noreply form (id+login@users.noreply.github.com), marks it unverified and does not link it to anything. Back all of this with a unique database constraint on (provider, provider_id) so two simultaneous first logins cannot create duplicate accounts. Social-only accounts also should not get a usable local password.
Java
@Service
public class GitHubAccountService {
private final AppUserRepository userRepo;
private final SocialAccountRepository socialRepo;
public GitHubAccountService(AppUserRepository userRepo, SocialAccountRepository socialRepo) {
this.userRepo = userRepo;
this.socialRepo = socialRepo;
}
@Transactional
public AppUser findOrCreate(String githubId, String verifiedEmail,
String name, String login, String avatarUrl) {
return socialRepo.findByProviderAndProviderId("github", githubId)
.map(SocialAccount::getUser)
.map(user -> refreshProfile(user, name, avatarUrl))
.orElseGet(() -> register(githubId, verifiedEmail, name, login, avatarUrl));
}
private AppUser register(String githubId, String verifiedEmail,
String name, String login, String avatarUrl) {
AppUser user;
if (verifiedEmail != null) {
Optional<AppUser> existing = userRepo.findByEmail(verifiedEmail);
if (existing.isPresent() && !existing.get().isEmailVerified()) {
// possible pre-registration attack: do not merge, ask the user to verify first
throw new OAuth2AuthenticationException(new OAuth2Error("email_conflict"),
"An unverified account already uses this email address.");
}
user = existing.orElseGet(() -> createUser(verifiedEmail, true, name, login, avatarUrl));
} else {
String noreply = githubId + "+" + login + "@users.noreply.github.com";
user = createUser(noreply, false, name, login, avatarUrl); // never linked by email
}
SocialAccount social = new SocialAccount();
social.setProvider("github");
social.setProviderId(githubId); // GitHub's numeric id, not the login name
social.setUser(user);
social.setLinkedAt(LocalDateTime.now());
socialRepo.save(social);
return user;
}
private AppUser createUser(String email, boolean emailVerified,
String name, String login, String avatarUrl) {
AppUser user = new AppUser();
user.setEmail(email);
user.setEmailVerified(emailVerified);
user.setName(name != null ? name : login);
user.setPictureUrl(avatarUrl);
user.setPassword(null); // social-only account: no local password
user.setEnabled(true);
user.setRoles(Set.of("USER"));
return userRepo.save(user);
}
private AppUser refreshProfile(AppUser user, String name, String avatarUrl) {
if (avatarUrl != null) user.setPictureUrl(avatarUrl);
if (name != null) user.setName(name);
return userRepo.save(user);
}
}Calling the GitHub API After Login
The access token is available inside the user service during login, but later requests need it from Spring Security's authorized client store. Inject it into a controller method with @RegisteredOAuth2AuthorizedClient and pass the token value to the API client. The token only carries the scopes you requested at login, so listing private repositories would require the broader repo scope, which you should request only if the feature really needs it. GitHub OAuth App tokens are long-lived, which makes them valuable to an attacker: do not log them, and do not store them in your database unless you must (if you do, encrypt them).
Java
@RestController
public class RepositoryController {
private final GitHubApiClient gitHubApi;
public RepositoryController(GitHubApiClient gitHubApi) {
this.gitHubApi = gitHubApi;
}
@GetMapping("/api/github/repos")
public List<GitHubApiClient.GitHubRepo> myRepositories(
@RegisteredOAuth2AuthorizedClient("github") OAuth2AuthorizedClient client) {
return gitHubApi.fetchRepositories(client.getAccessToken().getTokenValue());
}
}Testing the API Client
You cannot run the real GitHub flow in automated tests, but the logic that matters can be tested without it. @RestClientTest provides a MockRestServiceServer bound to the RestClient, so the test below checks that the primary verified address is chosen from a realistic /user/emails response, and that the organisation check returns false for a 404 but denies login (throws) for a 403. The 403 case is the one real deployments hit when an organisation restricts third-party apps. This test style works on Spring Boot 3.2 and later; Spring Boot 4 moved the @RestClientTest package, so check the import for your version.
Java
@RestClientTest(GitHubApiClient.class)
class GitHubApiClientTest {
@Autowired
private GitHubApiClient client;
@Autowired
private MockRestServiceServer server;
@Test
void picksThePrimaryVerifiedEmail() {
server.expect(requestTo("https://api.github.com/user/emails"))
.andExpect(header("Authorization", "Bearer token-123"))
.andRespond(withSuccess("""
[
{ "email": "old@example.com", "primary": false, "verified": true, "visibility": null },
{ "email": "me@example.com", "primary": true, "verified": true, "visibility": "private" },
{ "email": "new@example.com", "primary": false, "verified": false, "visibility": null }
]
""", MediaType.APPLICATION_JSON));
assertThat(client.fetchPrimaryVerifiedEmail("token-123")).contains("me@example.com");
}
@Test
void notAMember_returnsFalse() {
server.expect(requestTo("https://api.github.com/user/memberships/orgs/acme"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThat(client.isActiveMember("token-123", "acme")).isFalse();
}
@Test
void restrictedOrganisation_deniesLogin() {
server.expect(requestTo("https://api.github.com/user/memberships/orgs/acme"))
.andRespond(withStatus(HttpStatus.FORBIDDEN));
assertThatThrownBy(() -> client.isActiveMember("token-123", "acme"))
.isInstanceOf(OAuth2AuthenticationException.class);
}
}Errors You Will Probably Hit
"The redirect_uri MUST match the registered callback URL" means the callback registered in GitHub differs from what Spring sent: check host, port, http versus https (behind a proxy, set server.forward-headers-strategy=framework), and that you are using the OAuth App for the right environment. An email that is always null or falls back to the noreply address means user:email was not requested, or the user has no verified primary email. Repository fields that are always null mean the snake_case JSON properties are not mapped; add @JsonProperty. A ClassCastException in the user service comes from String.valueOf on the generic getAttribute call, described above. If the organisation check fails for people who are members, the organisation probably restricts third-party OAuth apps: an owner must approve the app under the organisation's third-party access settings, and users may need to grant access to the organisation when they authorize the app.
Text
Symptom | Most likely cause
------------------------------------------|----------------------------------------------
redirect_uri mismatch | wrong callback URL, http vs https, proxy headers
email is null / noreply address | missing user:email scope or no verified primary email
repo fields are null | snake_case JSON not mapped (@JsonProperty)
ClassCastException in loadUser | String.valueOf(getAttribute("id")) picked the char[] overload
org_check_failed for real members | org restricts third-party OAuth apps (403)
two accounts for one person | no unique (provider, provider_id) constraint