Spring Boot OAuth2 Social Login: Spring Security Tutorial
Spring Boot 15 min read Part of Spring Security with React
Part one of a Spring Security OAuth2 example: registering the providers, mapping three different user-info payloads onto one account, and issuing your own JWT instead of holding a session.
Spring Boot OAuth2 social login is one of those features that looks like configuration until you try to make three providers behave the same way. The protocol handshake really is a few properties. What takes the time is everything after the callback: Google, Facebook and GitHub each return a different user-info shape, and you have to decide what an “account” means when the same person can arrive through any of them.
This Spring Security OAuth2 example covers the setup and the account model, the half of a working OAuth2 social login example that no amount of configuration will write for you. Written against Spring Boot 3.2, Spring Security 6 and Java 17.
What this Spring Boot OAuth2 social login tutorial builds
A backend that lets a user sign in with any of the three providers and returns a JWT the frontend can use. Concretely:
GET /oauth2/authorize/googleredirects to the provider.- The provider calls back to
/login/oauth2/code/google. - Spring Security exchanges the code for tokens and fetches the user info.
- Our code maps that payload to a local
User, creating one on first sign-in. - We issue our own JWT and redirect to the frontend with it.
Step 4 is where the work is. Steps 1–3 are Spring Security doing its job.
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
oauth2-client is the one that matters. It is the client half of OAuth2. Do not reach for
spring-boot-starter-oauth2-resource-server here; that is for validating tokens other people
issued, which is a different job in a later part.
Registering the providers: a three-provider example
Google and Facebook are known to Spring Security, so they need only credentials. GitHub is known too, but its email handling needs a scope most examples omit.
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
facebook:
client-id: ${FACEBOOK_CLIENT_ID}
client-secret: ${FACEBOOK_CLIENT_SECRET}
scope:
- email
- public_profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope:
- user:email
provider:
facebook:
# Facebook returns only id and name unless the fields are requested explicitly.
user-info-uri: https://graph.facebook.com/me?fields=id,name,email,picture.width(250)
app:
jwt:
secret: ${JWT_SECRET}
expiration-ms: 864000000
oauth2:
authorized-redirect-uris:
- http://localhost:3000/oauth2/redirect
The redirect URI you register with each provider must match what Spring Security serves:
{baseUrl}/login/oauth2/code/{registrationId}, so http://localhost:8080/login/oauth2/code/google
in development. A mismatch produces a provider-side error page, not a Spring stack trace, which is
why it is worth checking first when nothing happens.
Credentials belong in environment variables. A client secret committed to a repository is compromised the moment the repository is shared, and rotating it means going back to three provider consoles.
The account model
The decision that shapes everything: one user, many providers, matched on verified email.
@Entity
@Table(name = "users",
uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Email @Column(nullable = false)
private String email;
private String imageUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider; // GOOGLE, FACEBOOK, GITHUB
private String providerId;
// getters and setters omitted
}
public enum AuthProvider { GOOGLE, FACEBOOK, GITHUB }
Matching on email is convenient and has a sharp edge worth stating plainly: if a provider does not verify the email it gives you, account matching becomes an account takeover. Someone registers an unverified account at a provider using your address, signs in, and lands in your user record.
Google returns an email_verified claim. GitHub marks the primary email verified through its API.
Facebook does not expose a verification flag at all. Check the flag where it exists, and treat
Facebook email as untrusted for matching: link it to an existing account only after the user
confirms while signed in.
Normalising three different payloads
Each provider names things differently. One small hierarchy keeps that mess out of the rest of the codebase:
public abstract class OAuth2UserInfo {
protected final Map<String, Object> attributes;
protected OAuth2UserInfo(Map<String, Object> attributes) { this.attributes = attributes; }
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> a) { super(a); }
public String getId() { return (String) attributes.get("sub"); }
public String getName() { return (String) attributes.get("name"); }
public String getEmail() { return (String) attributes.get("email"); }
public String getImageUrl() { return (String) attributes.get("picture"); }
public boolean isEmailVerified() {
return Boolean.TRUE.equals(attributes.get("email_verified"));
}
}
public class GithubOAuth2UserInfo extends OAuth2UserInfo {
public GithubOAuth2UserInfo(Map<String, Object> a) { super(a); }
public String getId() { return String.valueOf(attributes.get("id")); }
public String getName() { return (String) attributes.get("name"); }
public String getEmail() { return (String) attributes.get("email"); } // often null
public String getImageUrl() { return (String) attributes.get("avatar_url"); }
}
public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
public FacebookOAuth2UserInfo(Map<String, Object> a) { super(a); }
public String getId() { return (String) attributes.get("id"); }
public String getName() { return (String) attributes.get("name"); }
public String getEmail() { return (String) attributes.get("email"); }
@SuppressWarnings("unchecked")
public String getImageUrl() {
Map<String, Object> picture = (Map<String, Object>) attributes.get("picture");
if (picture == null) return null;
Map<String, Object> data = (Map<String, Object>) picture.get("data");
return data == null ? null : (String) data.get("url");
}
}
Three details that cost people an afternoon each:
- Google’s stable identifier is
sub, notid. It is the OIDC subject claim. - GitHub’s
idis a number, so casting it toStringthrows. UseString.valueOf. - GitHub’s
emailis usually null. The public profile omits it unless the user made it public. With theuser:emailscope you can fetchhttps://api.github.com/user/emailsand take the primary verified one, an extra call, and the only reliable way.
Wiring it into Spring Security
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
public CustomOAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(request); // the provider call happens here
try {
return process(request, oAuth2User);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
// Wrap: anything else would surface as a 500 instead of an auth failure.
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
private OAuth2User process(OAuth2UserRequest request, OAuth2User oAuth2User) {
String registrationId = request.getClientRegistration().getRegistrationId();
OAuth2UserInfo info = OAuth2UserInfoFactory.get(registrationId, oAuth2User.getAttributes());
if (!StringUtils.hasText(info.getEmail())) {
throw new OAuth2AuthenticationProcessingException("No email from " + registrationId);
}
User user = userRepository.findByEmail(info.getEmail())
.map(existing -> update(existing, info))
.orElseGet(() -> register(registrationId, info));
return UserPrincipal.create(user, oAuth2User.getAttributes());
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2AuthenticationSuccessHandler successHandler;
// constructor omitted
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // safe here: stateless, no cookies
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error", "/oauth2/**", "/login/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o
.authorizationEndpoint(a -> a.baseUri("/oauth2/authorize"))
.redirectionEndpoint(r -> r.baseUri("/login/oauth2/code/*"))
.userInfoEndpoint(u -> u.userService(oAuth2UserService))
.successHandler(successHandler));
return http.build();
}
}
Spring Security 6 uses the lambda DSL; the older http.csrf().disable() chain no longer compiles.
And disabling CSRF is only defensible because the session policy is STATELESS. No session
cookie means no cross-site request forgery vector. Leave sessions on and you have removed a real
protection.
Why issue your own JWT
The success handler mints a token and redirects to the frontend with it:
String token = tokenProvider.createToken(authentication);
String target = UriComponentsBuilder.fromUriString(redirectUri)
.queryParam("token", token)
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, target);
The provider’s own access token is for calling their API. It says nothing about roles or permissions in your system, its lifetime is theirs to decide, and validating it means a network call on every request. Your own JWT carries your user id and your authorities.
Validate redirectUri against the allowlist before redirecting. An unchecked redirect parameter
is an open redirect, and here it would leak the token to whatever host an attacker names.
Putting the token in a query string is the simplest way to pass it along, and it does end up in browser history and server logs. A short expiry limits the damage; an httpOnly cookie or a one-time exchange code is the sturdier option.
What comes next
Part two covers the JWT filter that authenticates subsequent requests and the security configuration around it; part three is the React client that starts the flow and collects the token. For username-and-password authentication over the same JWT machinery, see the JWT and React series. Everything above is the durable part of any Spring Boot OAuth2 social login build: the providers change, the normalisation layer and the account model do not.
If you take one thing from this OAuth2 social login example, make it the verified-email rule. Matching accounts on an unverified address is the difference between a convenience feature and an account-takeover path.
Frequently asked questions
Which starter do I need for Spring Boot OAuth2 social login?
spring-boot-starter-oauth2-client. The resource-server starter validates tokens issued elsewhere,
which is a different role.
What redirect URI do I register with the provider?
{baseUrl}/login/oauth2/code/{registrationId}, for example
http://localhost:8080/login/oauth2/code/google. It must match exactly.
Why does Facebook return only an id and a name?
Its user-info endpoint returns a minimal payload unless you request fields explicitly. Set
user-info-uri with a fields= parameter.
Why is the email null when signing in with GitHub?
GitHub omits it from the public profile unless the user made it public. Request the user:email
scope and call https://api.github.com/user/emails for the primary verified address.
Is matching accounts on email safe?
Only for providers that verify it. Google exposes email_verified and GitHub marks its primary
email verified; Facebook exposes no flag, so treat it as unverified and require confirmation before
linking.
Why cast GitHub’s id with String.valueOf?
It arrives as a number in the JSON payload, so a direct cast to String throws a
ClassCastException.
Can one user sign in with more than one provider?
Yes, if you model it that way. This part stores a single provider per user; a separate
user_connections table keyed by provider and provider id is the fuller design.
Why issue my own JWT instead of using the provider’s token?
The provider’s token authorises calls to their API and carries none of your roles. Your own token is self-contained, so no network call is needed to authorise a request.
Is disabling CSRF safe here?
Only because the session policy is stateless and no session cookie is issued. If you keep sessions or move the token into a cookie, CSRF protection must come back.
Is passing the token in a query string acceptable?
It is the simplest option and it leaks into history and logs. Keep the expiry short, or use an httpOnly cookie or a one-time exchange code instead.
How do I test this locally?
Register http://localhost:8080 callbacks in each provider console. Google and GitHub both accept
localhost; Facebook requires the app to be in development mode with your account as a tester.