Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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

application.yml
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

Each provider returns a different shape. Normalise once so the rest of the application sees one type.
CustomOAuth2UserService.java
@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:

AccountProvisioning.java
@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:

OnboardingSuccessHandler.java
@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

User.java
@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:

FailureHandling.java
.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?
Only when the provider asserts the email is verified, and even then be careful. Linking on an unverified email lets an attacker register at a permissive provider using a victim address and take over the existing account. Check email_verified, and for sensitive applications require the user to confirm the link while signed in.
Why does GitHub not return an email?
GitHub only includes email in the profile if it is public. Request the user:email scope and call the /user/emails endpoint to get the verified primary address. Several providers have similar quirks, which is why a per-provider extraction step is usually necessary.
Should social login replace passwords entirely?
It is a reasonable choice and it moves credential security to the provider. The trade-off is losing users who do not have or want an account with your chosen providers, and a hard dependency on their availability. Many applications offer both.

Related tutorials