Spring Boot OAuth2 Social Login: Spring Security — Part 2
Spring Boot 13 min read Part of Spring Security with React
The security configuration: the OAuth2 login flow end to end, a cookie-based authorization request repository so it survives a stateless backend, mapping provider profiles to one user, and issuing a JWT at the end of it.
Part 1 built the entities, repositories and configuration. This part is the security layer: the
SecurityFilterChain, the pieces of the OAuth2 login flow that need customising, and the token
handed back to a single-page application at the end.
The awkward part of OAuth2 with a stateless API is not the protocol. It is that Spring’s default implementation stores the in-flight authorization request in the HTTP session, and a JWT-based backend does not have one. That is the problem the middle of this article solves.
Written against Spring Boot 3.2, Spring Security 6.2 and Java 17.
The flow, once
Worth having in mind before the code, because each class below owns one step:
- Browser hits
/oauth2/authorize/google. Spring generates an authorization request, stores it, and redirects to Google. - User authenticates at Google. Google redirects back to
/oauth2/callback/googlewith a code and thestatevalue. - Spring retrieves the stored request, checks
stateagainst it, exchanges the code for tokens. - Spring calls the provider’s user-info endpoint and hands the result to an
OAuth2UserService. - On success, an
AuthenticationSuccessHandlerdecides where the browser goes next.
Steps 1 and 3 are the pair that needs a store. Steps 4 and 5 are where your own user model and token appear.
Registering providers
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
scope:
- email
- profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
scope:
- user:email
- read:user
app:
oauth2:
authorized-redirect-uris:
- http://localhost:3000/oauth2/redirect
Google and GitHub are known providers, so endpoints are filled in from
CommonOAuth2Provider and only credentials are needed.
{baseUrl} and {registrationId} are placeholders Spring expands, which keeps one value working
across environments. The literal string must match what you registered with the provider exactly —
including the port, and including http versus https. A mismatch produces the provider’s own error
page rather than anything from your application, which is a confusing first experience.
authorized-redirect-uris is your own property, and it is a security control rather than
configuration convenience. The front-end passes a redirect_uri to be sent back to after login; an
unvalidated value there is an open redirect that will happily forward a fresh token to any host.
The filter chain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2AuthenticationSuccessHandler successHandler;
private final OAuth2AuthenticationFailureHandler failureHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository requestRepository;
private final TokenAuthenticationFilter tokenFilter;
// constructor omitted
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(e -> e
.authenticationEntryPoint(new RestAuthenticationEntryPoint()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error", "/favicon.ico").permitAll()
.requestMatchers("/auth/**", "/oauth2/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/polls/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o
.authorizationEndpoint(a -> a
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(requestRepository))
.redirectionEndpoint(r -> r
.baseUri("/oauth2/callback/*"))
.userInfoEndpoint(u -> u
.userService(oAuth2UserService))
.successHandler(successHandler)
.failureHandler(failureHandler))
.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Three of those lines are the kind that get copied without being understood, so:
csrf.disable() is only correct because the API is stateless and the token is not a cookie. If
you ever store the JWT in a cookie, CSRF protection must come back on, a cookie is sent
automatically by the browser, which is exactly the condition CSRF exploits.
SessionCreationPolicy.STATELESS is what forces the cookie-based request repository below. There
is no session to put the authorization request in.
RestAuthenticationEntryPoint returns 401 rather than redirecting to a login page. Without it an
unauthenticated API call answers with a 302 to HTML, which a fetch client reports as an opaque CORS
failure, one of the more misleading symptoms in this stack.
The authorization request repository
Spring’s default is HttpSessionOAuth2AuthorizationRequestRepository. With sessions off, the request
saved in step 1 is gone by step 3, and the callback fails state validation with
authorization_request_not_found. The fix is to keep it in a short-lived cookie:
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String AUTH_REQUEST_COOKIE = "oauth2_auth_request";
public static final String REDIRECT_URI_COOKIE = "redirect_uri";
private static final int EXPIRY_SECONDS = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtils.getCookie(request, AUTH_REQUEST_COOKIE)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(request, response);
return;
}
CookieUtils.addCookie(response, AUTH_REQUEST_COOKIE,
CookieUtils.serialize(authorizationRequest), EXPIRY_SECONDS);
String redirectUri = request.getParameter("redirect_uri");
if (StringUtils.hasText(redirectUri)) {
CookieUtils.addCookie(response, REDIRECT_URI_COOKIE, redirectUri, EXPIRY_SECONDS);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
HttpServletResponse response) {
return loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request,
HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, AUTH_REQUEST_COOKIE);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_COOKIE);
}
}
Set the cookies HttpOnly, Secure in production, and SameSite=Lax: Lax rather than Strict
because the callback arrives as a cross-site navigation from the provider, and Strict would withhold
the cookie exactly when it is needed. Three minutes is ample; this cookie lives only for the duration
of a redirect round trip.
Mapping a provider profile to your user
Every provider returns a different shape. Normalise behind one interface:
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> attributes) { super(attributes); }
@Override public String getId() { return (String) attributes.get("sub"); }
@Override public String getName() { return (String) attributes.get("name"); }
@Override public String getEmail() { return (String) attributes.get("email"); }
@Override public String getImageUrl() { return (String) attributes.get("picture"); }
}
public class GithubOAuth2UserInfo extends OAuth2UserInfo {
public GithubOAuth2UserInfo(Map<String, Object> attributes) { super(attributes); }
@Override public String getId() { return String.valueOf(attributes.get("id")); }
@Override public String getName() { return (String) attributes.get("name"); }
@Override public String getEmail() { return (String) attributes.get("email"); }
@Override public String getImageUrl() { return (String) attributes.get("avatar_url"); }
}
getId() differs by more than the key name: Google’s sub is a string, GitHub’s id is a number.
Casting GitHub’s to String throws ClassCastException, which is a genuinely common first bug here.
Then the service that turns a provider profile into your User:
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository users;
public CustomOAuth2UserService(UserRepository users) {
this.users = users;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(request);
try {
return process(request, oAuth2User);
} catch (AuthenticationException e) {
throw e;
} catch (Exception e) {
// wrap anything else so the failure handler runs instead of a 500
throw new InternalAuthenticationServiceException(e.getMessage(), e.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 found from " + registrationId);
}
User user = users.findByEmail(info.getEmail())
.map(existing -> updateExisting(existing, info, registrationId))
.orElseGet(() -> register(info, registrationId));
return UserPrincipal.create(user, oAuth2User.getAttributes());
}
}
Two decisions in there worth making deliberately.
Matching on email links accounts across providers. Signing in with Google and later with GitHub
using the same address gives one account. That is usually the desired behaviour, and it means you are
trusting the provider’s email. Google verifies; GitHub’s /user endpoint returns null for email
when the user has made it private, which is why the explicit check above exists: request the
user:email scope and call /user/emails if you need it reliably.
Wrapping unexpected exceptions in InternalAuthenticationServiceException matters because
Spring’s failure handler only runs for AuthenticationException. Anything else becomes a 500 and the
user sees a blank error page instead of being redirected back to your front-end with a message.
Finishing the login
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final AppProperties appProperties;
private final HttpCookieOAuth2AuthorizationRequestRepository requestRepository;
// constructor omitted
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("Response already committed; cannot redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request);
requestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
@Override
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
Optional<String> redirectUri =
CookieUtils.getCookie(request, REDIRECT_URI_COOKIE).map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("Unauthorized redirect URI");
}
String target = redirectUri.orElse(getDefaultTargetUrl());
String token = tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(target)
.queryParam("token", token)
.build().toUriString();
}
private boolean isAuthorizedRedirectUri(String uri) {
URI client = URI.create(uri);
return appProperties.getOauth2().getAuthorizedRedirectUris().stream()
.map(URI::create)
.anyMatch(authorized ->
authorized.getHost().equalsIgnoreCase(client.getHost())
&& authorized.getPort() == client.getPort());
}
}
isAuthorizedRedirectUri compares host and port only, not the whole string, so the front-end may vary
the path. Comparing the full URI would be stricter; comparing only the host would allow any path on
that host, which is the usual trade and acceptable when you control the host.
The token goes back as a query parameter, which is the pragmatic choice for a browser redirect and
has a real cost: query strings land in browser history, server access logs and Referer headers. Mitigate
by keeping the token short-lived and having the front-end strip it from the URL immediately:
const token = new URLSearchParams(window.location.search).get('token');
localStorage.setItem('accessToken', token);
window.history.replaceState({}, document.title, '/');
Issuing and validating the token
@Service
public class TokenProvider {
private final SecretKey key;
private final long expirationMs;
public TokenProvider(AppProperties props) {
this.key = Keys.hmacShaKeyFor(props.getAuth().getTokenSecret().getBytes(StandardCharsets.UTF_8));
this.expirationMs = props.getAuth().getTokenExpirationMsec();
}
public String createToken(Authentication authentication) {
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
Instant now = Instant.now();
return Jwts.builder()
.subject(Long.toString(principal.getId()))
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusMillis(expirationMs)))
.signWith(key)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
return Long.parseLong(claims.getSubject());
}
}
Keys.hmacShaKeyFor requires at least 256 bits, a secret shorter than 32 bytes throws
WeakKeyException at startup, which is the library refusing to let you sign with something guessable.
Generate it randomly and keep it in the environment.
Jwts.parser().verifyWith(...) is the JJWT 0.12 API; earlier versions used setSigningKey and
parseClaimsJws. Mixing the two is a common compile error when following older material.
The filter that runs on every request:
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
// constructor omitted
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt)) {
Long userId = tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails = userDetailsService.loadUserById(userId);
var authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Could not set user authentication in security context", e);
}
chain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
Note that it swallows exceptions and continues rather than rejecting. An invalid token leaves the
context unauthenticated, and the authorization rules then produce a 401, the filter’s job is to
populate the context, not to decide access. Extending OncePerRequestFilter guarantees it runs once
even when the request is dispatched internally.
Frequently asked questions
Why do I get authorization_request_not_found?
Sessions are stateless, so the default
session-backed repository has nothing to load at the callback. Supply a cookie-based
AuthorizationRequestRepository.
Why does the provider show its own error page before reaching my app?
The redirect-uri does not
match what is registered with the provider. It must match exactly, including scheme and port.
Is it safe to disable CSRF?
Only while the token travels in the Authorization header and no
session cookie authenticates the request. If you move the JWT into a cookie, CSRF protection must
come back.
Why is GitHub’s email null?
The user has made it private. Request the user:email scope and read
/user/emails, and fail explicitly when no verified address is available.
Why does my GitHub login throw ClassCastException?
GitHub’s id is a number, Google’s sub is a
string. Use String.valueOf(...) rather than casting.
Should the JWT go in the redirect URL?
It is the practical option for a browser redirect, and
query strings leak into history, logs and Referer. Keep the token short-lived and have the client
strip it from the URL immediately.
Why must I validate the redirect URI?
Without an allow-list, anyone can send a user through login and have a freshly issued token delivered to a host they control. That is an account takeover, not an open-redirect nuisance.
Why is my unauthenticated API call failing CORS instead of returning 401?
The default entry point
redirects to a login page, and the browser reports the cross-origin redirect as a CORS error. Return
401 from a RestAuthenticationEntryPoint.
Why does WeakKeyException happen at startup?
The HMAC-SHA key is under 256 bits. Use a random secret of at least 32 bytes, from the environment.
Why does my custom user service produce a 500 instead of a redirect?
Spring’s failure handler only
handles AuthenticationException. Wrap other exceptions in
InternalAuthenticationServiceException.
Where should I go next?
Part 1 covers the entities and configuration this builds on, and JWT with a React front-end covers email-and-password authentication on the same stack.