Challenges in adding OAuth2 / OIDC authentication to secure existing applications
I am a fan of teaching or leading by example, and here I will try to give you some of my struggles, experiences and (re)learnings during implementing some security features in this blog application that I have been working these past days.
Allowing users to start using your application without complications is a fundamental concept of User Experience called time to value.
Let's say you're building an application that needs an account to work properly.
Many companies require users to fill out lengthy forms, which takes valuable time away from the end user who just wants to start using your application.
Not only that, but there's significant effort in maintaining your registration process: managing new accounts, forgotten passwords, Multi-Factor Authentication, and more. This diverts your engineering team's focus from building the actual value your application provides.
This is where OAuth2 and OIDC comes into the picture.

OAuth2 allows users to log in using pre-existing accounts with providers like Google, Microsoft, Facebook, Okta, GitHub, and many others—without spending months developing your own custom authentication process. Building authentication from scratch is not only time-consuming but also risky if not implemented with proper safeguards to protect your data and operations.
But I'm not here to sell you on implementing OAuth2. Instead, I want to show you some key challenges you'll face during implementation:
The Problem with Using the Wrong Identifier from OAuth2 Attributes
OAuth2 was originally built for authorization, not authentication. One major issue is the lack of a standard field returned by endpoints to use as a user identifier.
What happens if you implement GitHub OAuth2 login and save the GitHub username as the user ID in your database?
Well, GitHub allows users to change their usernames! If a user changes their GitHub username after logging into your application, your system won't recognize them on their next login—it will create a new account instead.
If you didn't save the right field initially, you'll have no way to verify the user's identity later. Consider this example:
- GitHub username is
hacker
- User logs into your system for the first time; your database saves
hacker
as the identifier - User changes their GitHub username to
no_more_hackers
- User logs in again, but your system doesn't recognize
no_more_hackers
Now you have no way to confirm this is the same user. You only know they're authenticated against GitHub, not whether they're an existing user in your database.
This is just one of many OAuth2 pitfalls, and it's why OpenID Connect (OIDC) was created to address these issues. However, not all Authorization Servers or Identity Providers (like Facebook or GitHub) have implemented OIDC, leaving you to handle legacy systems.
So you better make sure which is the right field for each Providers. Some of them is sub
, others is oid
, others it's id
, and so forth. And save it in a database paired with the actual provider (eg: github + id, google + sub, etc).
Why Automatically Merging Accounts Between Providers is Dangerous
Imagine you've implemented OAuth2 with both Google and Facebook, and both return the same email address: example@gmail.com
.
You might think, "Great! I can merge both accounts into the same user identity on my server, right?"
Don't do it.
Let me introduce the concept of an Authoritative Identity Provider (IDP):
Authoritative IDP: The provider that has authority over a particular identifier. For example, Google is authoritative for @gmail.com addresses, while Yahoo is authoritative for @yahoo.com addresses. When Google confirms someone has xyz@gmail.com, you can trust they control that email.
So what's the problem if both Google and Facebook return example@gmail.com
?
You can only trust the authoritative IDP (Google) that the email is valid and verified. Facebook might have this email in their system, but it could be unverified—entered by the user but never confirmed.
If you automatically link these accounts in your system, you'll create a security vulnerability that's nearly impossible to undo without causing major headaches for your engineering team.
Collecting Profile Information After First Login
Most applications need additional user information beyond what OAuth2 provides. The best approach is to collect this data immediately after the user's first successful authentication.
But what if the user closes the window before completing their profile?
Your system should remember where they left off. The most effective solution is to implement logic that:
- Detects authenticated users with incomplete profiles
- Redirects them to complete their profile before proceeding
Here's how this blog implements this pattern using Spring Boot's filter mechanism:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() &&
auth instanceof OAuth2AuthenticationToken) {
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) auth;
// Get the user_id that was added in CustomOAuth2UserService/CustomOidcUserService
Long userId = (Long) token.getPrincipal().getAttribute("user_id");
if (userId != null) {
User user = userService.findById(userId);
if (user.getUsername() == null || user.getUsername().trim().isEmpty()) {
// Save the current request before redirecting
requestCache.saveRequest(httpRequest, httpResponse);
httpResponse.sendRedirect("/complete-profile");
return;
}
}
}
This code snippet highlights another crucial challenge:
Returning Users to Their Original Destination
When users start the login flow, you need to remember where they were and redirect them back after authentication:
- User visits
/viewpost/1/some-title
- User initiates login
- User is redirected to the identity provider's login form
- User is redirected back to your application
The challenge? That last step doesn't automatically return users to their original page (/viewpost/1/some-title
). Identity providers typically support only a single redirect-uri
, usually your homepage (/
).
Spring Boot provides RequestCache
to handle this elegantly. Here's some code snippets on how it can be done:
SecurityConfig.java:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
...
@Bean
public RequestCache requestCache() {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
return requestCache;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ProfileCompletionFilter profileCompletionFilter) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/static/**", "/login", "/logout",
"/viewpost/**",
"/uploads/**", "/css/**", "/img/**", "/js/**").permitAll()
.requestMatchers("/writepost", "/posts/*/edit").hasRole("AUTHOR")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.successHandler(new SavedRequestAwareAuthenticationSuccessHandler())
.defaultSuccessUrl("/", false)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService)
))
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
.requestCache(cache -> cache
.requestCache(requestCache()))
return http.build();
}
...
}
In your controllers:
private String redirectToOriginalUrl(HttpServletRequest request, HttpServletResponse response) {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String targetUrl = savedRequest.getRedirectUrl();
UriComponents components = UriComponentsBuilder.fromUriString(targetUrl).build();
String path = components.getPath();
String query = components.getQuery();
String relativeUrl = (path != null ? path : "/");
if (query != null) {
relativeUrl += "?" + query;
}
response.setHeader("HX-Redirect", relativeUrl);
return null;
}
// fallback
response.setHeader("HX-Redirect", "/");
return null;
}
Applying SOLID Principles for Maintainable OAuth2 Implementation
Since you'll likely support multiple providers, following SOLID principles will make adding new identity providers straightforward and keep your codebase maintainable.
Single Responsibility Principle (SRP)
Each provider service has one clear responsibility: extracting user data from its specific OAuth2/OIDC provider.
@Component("google")
public class OidcGoogleProviderService implements OidcProviderService {
@Override
public String getUsername(OidcUser oidcUser) {
return oidcUser.getClaimAsString("email");
}
// Only handles Google-specific data extraction
}
Result: Each service class focuses solely on one provider's data extraction logic.
Open/Closed Principle (OCP)
Your system should be open for extension but closed for modification. Add new providers without changing existing code:
@Component("microsoft") // New provider - no existing code modified
public class OidcMicrosoftProviderService implements OidcProviderService {
@Override
public String getUsername(OidcUser oidcUser) {
return oidcUser.getAttribute("email");
}
}
New OAuth2 providers can be added by implementing the interface without modifying CustomOAuth2UserService
.
Interface Segregation Principle (ISP)
Create separate interfaces for different OAuth2 protocols—clients only depend on the methods they need:
public interface OAuth2ProviderService {
String getUniqueId(OAuth2User oauthUser);
String getUsername(OAuth2User oauthUser);
// OAuth2-specific methods only
}
public interface OidcProviderService {
default String getUniqueId(OidcUser oidcUser) {
return oidcUser.getSubject();
}
String getUsername(OidcUser oidcUser);
// OIDC-specific methods only
}
OAuth2 and OIDC providers implement only the interface relevant to their protocol.
Dependency Inversion Principle (DIP)
High-level modules should depend on abstractions, not concrete implementations:
@Service
public class CustomOAuth2UserService {
private final Map<String, OAuth2ProviderService> providerServices;
public CustomOAuth2UserService(Map<String, OAuth2ProviderService> providerServices) {
this.providerServices = providerServices; // Depends on abstraction
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
String provider = request.getClientRegistration().getRegistrationId();
OAuth2ProviderService providerService = providerServices.get(provider);
// Uses interface, not concrete class
}
}
Conclusion
Implementing OAuth2/OIDC authentication comes with hidden challenges that go beyond the initial setup. By understanding these pitfalls—from choosing the right user identifiers to handling post-login flows—you can build a robust authentication system that provides a seamless user experience while maintaining security and flexibility.