How to Capture Custom OAuth2 Token Response Fields and Map Them to JWT Claims in Spring Authorization Server
Integrating with legacy or non-standard third-party OAuth2 providers can be tricky—especially when the provider omits standard OpenID Connect (OIDC) endpoints and instead returns identity attributes directly inside the token response body:
{
"access_token": "opaque_token_here",
"refresh_token": "refresh_token_here",
"expires_in": 3600,
"id": "unique-user-id",
"token_type": "bearer"
}If your application serves as a downstream Spring Authorization Server (SAS), you need to capture this custom id, persist the tokens into a custom database, and use that id as the sub (subject) claim when minting your own downstream JWT tokens.
Here is the recommended, idiomatic Spring Security architecture to achieve this without hacking custom filters.
The Solution Architecture
Rather than overriding the entire authorization code exchange, you can hook into Spring Security’s existing OAuth2 Login pipeline at two points:
- Custom
OAuth2UserService: ExtractadditionalParametersfrom the token response, persist your tokens to the database, and return a customOAuth2Userwhose principal identifier is the externalid. OAuth2TokenCustomizer<JwtEncodingContext>: Inspect the authenticated principal in Spring Authorization Server and map the externalidinto the downstream JWT’ssubclaim.
Step 1: Custom OAuth2UserService for Extraction and Persistence
When an IdP does not expose a user-info-uri, Spring Security's DefaultOAuth2UserService cannot make a UserInfo call. By implementing a custom OAuth2UserService, you can bypass the UserInfo call completely and extract custom claims directly from the token response via userRequest.getAdditionalParameters().
package com.example.security;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final ExternalTokenRepository tokenRepository;
public CustomOAuth2UserService(ExternalTokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 1. Retrieve the extra fields returned in the token response body
Map<String, Object> additionalParameters = userRequest.getAdditionalParameters();
String externalUserId = (String) additionalParameters.get("id");
if (externalUserId == null) {
throw new OAuth2AuthenticationException("Missing 'id' parameter in OAuth2 token response");
}
// 2. Persist the token response in your database
String accessToken = userRequest.getAccessToken().getTokenValue();
String refreshToken = (userRequest.getAccessToken() != null && additionalParameters.containsKey("refresh_token"))
? (String) additionalParameters.get("refresh_token")
: null;
tokenRepository.saveExternalToken(
externalUserId,
accessToken,
refreshToken,
userRequest.getClientRegistration().getRegistrationId()
);
// 3. Assemble attributes and construct the OAuth2User
Map<String, Object> attributes = new HashMap<>(additionalParameters);
attributes.put("sub", externalUserId);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
"sub" // Sets externalUserId as the primary 'name' attribute
);
}
}Step 2: Ensure Extra Attributes Are Parsed
By default, Spring Security’s DefaultMapOAuth2AccessTokenResponseConverter keeps all unknown JSON keys in OAuth2AccessTokenResponse.getAdditionalParameters(). However, to guarantee snake_case or proprietary types parse properly, configure an explicit OAuth2AccessTokenResponseClient bean:
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
OAuth2AccessTokenResponseHttpMessageConverter converter = new OAuth2AccessTokenResponseHttpMessageConverter();
// Retain all non-standard attributes in the response
converter.setAccessTokenResponseConverter(new DefaultMapOAuth2AccessTokenResponseConverter());
RestTemplate restTemplate = new RestTemplate(List.of(
new FormHttpMessageConverter(),
converter
));
client.setRestOperations(restTemplate);
return client;
}Step 3: Map the External ID to the Spring Authorization Server JWT
When downstream clients request a token from your authorization server, SAS provides the OAuth2TokenCustomizer<JwtEncodingContext> extension point. Use this to override the subject (sub) claim using the OAuth2User constructed earlier:
package com.example.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
@Configuration
public class AuthorizationServerConfig {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
return (context) -> {
// Target both access tokens and id tokens if applicable
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
Authentication principal = context.getPrincipal();
if (principal.getPrincipal() instanceof OAuth2User oauth2User) {
String externalId = oauth2User.getAttribute("sub");
if (externalId != null) {
// Override the 'sub' claim in the downstream token
context.getClaims().subject(externalId);
}
}
}
};
}
}Step 4: Register in the Security Filter Chain
Wire the custom user service and response client into your standard oauth2Login configuration:
@Bean
public SecurityFilterChain defaultSecurityFilterChain(
HttpSecurity http,
CustomOAuth2UserService customOAuth2UserService,
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> tokenResponseClient) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient))
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
);
return http.build();
}Summary
With this setup:
- Spring Security handles the code exchange seamlessly without custom filters or endpoints.
- Custom fields (such as
id) are captured safely in memory and persisted into your custom table. - Spring Authorization Server automatically uses that identifier as the
subclaim for client applications, keeping your identity federation standards-compliant and secure.