Social Login — Google, GitHub, Microsoft
Adding sign in with Google, GitHub and Microsoft: client registration, mapping provider profiles to your user model, safe account linking, and the onboarding flow afterwards.
On this page
Social login removes password storage, reset flows and credential breaches from your responsibility. What it adds is provider-specific profile handling and one genuinely dangerous decision: how to link a social identity to an existing account.
Key Takeaways
- One dependency and a few properties gets you a working flow.
- Every provider returns a different profile shape — normalise per provider.
- Link accounts only on a verified email, and prefer explicit confirmation.
- Key users on provider plus subject, never on email alone.
- Plan the onboarding step — the provider gives you identity, not your application's data.
Configuration
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: 'openid,profile,email'
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
# GitHub omits email from the profile unless it is public, so the
# extra scope plus a second call is required.
scope: 'read:user,user:email'
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope: 'openid,profile,email'
authorization-grant-type: authorization_code
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
provider:
azure:
issuer-uri: 'https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0'Google and GitHub need no provider block — Spring ships their endpoints in CommonOAuth2Provider.
Anything else is configured by issuer-uri for OIDC providers, or explicit endpoints otherwise.
Request the narrowest scope that works. read:user plus user:email is enough to identify a GitHub
user; adding repo turns a sign-in button into a request for access to every private repository the
user owns, and a consent screen that reads like that costs you signups.
The redirect URI is the part that most often breaks in deployment. Spring expands {baseUrl} from the
incoming request, so behind an ingress or load balancer that terminates TLS it will cheerfully build an
http:// URI against an internal hostname, which then fails to match what you registered. Set
server.forward-headers-strategy: framework so X-Forwarded-Proto and X-Forwarded-Host are
honoured, and register the exact production URI at every provider — they match it literally, and a
trailing slash is a different URI.
Normalising the profile
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository users;
private final RestClient github = RestClient.create();
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oauth2User = super.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId();
SocialProfile profile = switch (registrationId) {
case "google" -> new SocialProfile("google",
oauth2User.getAttribute("sub"),
oauth2User.getAttribute("email"),
Boolean.TRUE.equals(oauth2User.getAttribute("email_verified")),
oauth2User.getAttribute("name"),
oauth2User.getAttribute("picture"));
case "github" -> {
// GitHub needs a second call for a verified email.
var email = fetchGithubPrimaryEmail(request.getAccessToken().getTokenValue());
yield new SocialProfile("github",
String.valueOf((Integer) oauth2User.getAttribute("id")),
email.address(), email.verified(),
oauth2User.getAttribute("name"),
oauth2User.getAttribute("avatar_url"));
}
case "azure" -> new SocialProfile("azure",
oauth2User.getAttribute("oid"),
oauth2User.getAttribute("preferred_username"),
true, // Azure AD addresses are directory-managed
oauth2User.getAttribute("name"),
null);
default -> throw new OAuth2AuthenticationException("unsupported provider");
};
User user = provision(profile);
return new AcmeOAuth2User(user, oauth2User.getAttributes(), authoritiesFor(user));
}
}One detail catches nearly everyone: this hook only runs for plain OAuth 2 providers. Google and
Microsoft are OIDC providers, so Spring resolves their principal through OidcUserService instead, and
a CustomOAuth2UserService registered on its own is never called for them. Register both —
.userInfoEndpoint(ui -> ui.userService(oauth2UserService).oidcUserService(oidcUserService)) — or the
google branch of that switch is dead code, and you will lose an afternoon working out why.
Account linking
This is where the security decision lives:
@Transactional
public User provision(SocialProfile profile) {
// 1. Already linked? Return it. This is the common path.
Optional<User> linked = users.findByProviderAndSubject(profile.provider(), profile.subject());
if (linked.isPresent()) {
User user = linked.get();
user.refreshFromProvider(profile.displayName(), profile.avatarUrl());
return user;
}
// 2. An existing account with this email? Linking here is the dangerous
// step — do it only when the provider VERIFIED the address, otherwise
// an attacker registers at a lax provider with a victim's email and
// inherits their account.
if (profile.emailVerified()) {
Optional<User> byEmail = users.findByEmailIgnoreCase(profile.email());
if (byEmail.isPresent()) {
User user = byEmail.get();
// For anything sensitive, require confirmation instead of linking
// silently: email the account owner, or ask them to sign in first.
if (user.requiresExplicitLinkConfirmation()) {
throw new AccountLinkConfirmationRequiredException(user.id(), profile);
}
user.linkIdentity(profile.provider(), profile.subject());
return user;
}
}
// 3. New user.
return users.save(User.fromSocial(profile));
}The three-branch structure matters. Looking up by provider plus subject first means an email change at the provider does not orphan the account. Falling back to email only when verified closes the takeover path. And requiring confirmation for sensitive accounts turns a silent link into a deliberate one.
Never key the account on email alone. Email addresses change hands, and a user who updates theirs at Google would otherwise become a stranger to your system.
Onboarding
A provider gives you identity. It does not give you the tenant, the plan, the accepted terms or whatever else your application needs:
@Component
public class OnboardingSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException {
var principal = (AcmeOAuth2User) authentication.getPrincipal();
if (!principal.user().onboardingComplete()) {
// Send new users to complete their profile rather than dropping
// them into an application that half works.
getRedirectStrategy().sendRedirect(request, response, "/onboarding");
return;
}
super.onAuthenticationSuccess(request, response, authentication);
}
}Keep the post-login destination in the session rather than a query parameter, and validate it against an allowlist before redirecting. An open redirect on the success handler is a phishing primitive: an attacker walks a victim through a genuine sign-in flow and lands them on a page they control, carrying the trust of having just authenticated.
Multiple identities per user
@Entity
public class User {
@Id private UUID id;
private String email;
// One user, several sign-in methods — Google at home, Microsoft at work,
// plus a password. Unique on (provider, subject).
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<LinkedIdentity> identities = new HashSet<>();
public void unlink(String provider) {
// Refuse to remove the last sign-in method, or the user is locked out
// of their own account with no recovery path.
if (identities.size() == 1 && passwordHash == null) {
throw new CannotRemoveLastIdentityException();
}
identities.removeIf(i -> i.provider().equals(provider));
}
}That guard is worth writing early. Unlinking the only identity on an account with no password produces a support ticket that requires manual database intervention to resolve.
Store the provider subject as text even when the provider issues a number. GitHub returns an integer id, Google a numeric string, Azure a UUID; one text column keeps a single table workable across all of them and saves a migration the first time you add a provider that does not fit the shape you guessed.
Failure handling
Providers have outages, users cancel consent, and tokens get revoked. Handle it explicitly:
.oauth2Login(oauth -> oauth
.failureHandler((request, response, exception) -> {
String error = exception instanceof OAuth2AuthenticationException oauthEx
? oauthEx.getError().getErrorCode() : "unknown";
// access_denied means the user declined consent — not an error to
// log loudly, and worth a friendly message rather than a stack trace.
response.sendRedirect("/login?error=" + URLEncoder.encode(error, UTF_8));
}))Offer at least two providers, or a provider outage locks out everyone. If you also support passwords, a user who cannot sign in with Google still has a route in.
What to take away
Normalise each provider's profile into one type, because none of them agree. Key accounts on provider plus subject, and link by email only when the provider verified it — preferably with explicit confirmation. Support multiple identities per user, refuse to unlink the last one, and route new users through onboarding rather than into a half-configured application.
Frequently Asked Questions
Is it safe to link accounts by email address?
Why does GitHub not return an email?
Should social login replace passwords entirely?
Related tutorials
- OpenID Connect (OIDC)The identity layer on OAuth 2.0: what an ID token is and how to validate it, standard scopes and claims, discovery, and single logout across relying parties.
- SAML 2.0 AuthenticationEnterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.
- OAuth 2.0 Resource ServerValidating tokens correctly: NimbusJwtDecoder configuration, issuer and audience validators, mapping claims to authorities, opaque token introspection and multi-tenant decoding.
- LDAP & Active Directory IntegrationAuthenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.