Implementing JWT Token Refresh in Spring Boot OAuth2
This article explains how to implement seamless JWT token refresh in a Spring Boot OAuth2 application by embedding refresh information into the access token, customizing token services, defining a token refresh executor, and handling the refreshed token on the front‑end with an Axios interceptor.
The author describes the challenges of using JWT for authentication in a Spring Boot OAuth2 project, especially token refresh and revocation, and focuses on a solution for transparent JWT token refresh.
Two refresh strategies are introduced: refreshing before token validation and refreshing after validation fails. Most public solutions adopt the latter, where an expired token triggers a request using refresh_token to obtain a new access_token .
After testing both approaches, the author prefers the first strategy (refresh before removal) because it keeps the old token available for processing and works better with other refresh mechanisms.
The core of the refresh logic is in DefaultTokenServices.loadAuthentication , which reads the token, checks expiration, and throws InvalidTokenException when necessary:
public OAuth2Authentication loadAuthentication(String accessTokenValue) throws AuthenticationException, InvalidTokenException {
OAuth2AccessToken accessToken = tokenStore.readAccessToken(accessTokenValue);
if (accessToken == null) {
throw new InvalidTokenException("Invalid access token: " + accessTokenValue);
} else if (accessToken.isExpired()) {
// token is removed after expiration
tokenStore.removeAccessToken(accessToken);
throw new InvalidTokenException("Access token expired: " + accessTokenValue);
}
// ... other logic ...
return result;
}Because JwtTokenStore.removeAccessToken is a no‑op, the old JWT can still be retrieved in OAuth2AuthenticationEntryPoint , allowing the refresh process to run.
To make the refresh possible, the author extends JwtAccessTokenConverter (named OauthJwtAccessTokenConverter ) and overrides enhance to embed refresh_token , client_id , and client_secret into the JWT:
public class OauthJwtAccessTokenConverter extends JwtAccessTokenConverter {
private JsonParser objectMapper = JsonParserFactory.create();
public OauthJwtAccessTokenConverter(SecurityUserService userService) {
super.setAccessTokenConverter(new OauthAccessTokenConverter(userService));
}
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken);
Map
info = new LinkedHashMap<>(accessToken.getAdditionalInformation());
String tokenId = result.getValue();
if (!info.containsKey(TOKEN_ID)) {
info.put(TOKEN_ID, tokenId);
} else {
tokenId = (String) info.get(TOKEN_ID);
}
// add client_id / client_secret from authentication details
Map
details = (Map
) authentication.getUserAuthentication().getDetails();
if (details != null && !details.isEmpty()) {
info.put(OauthConstant.OAUTH_CLIENT_ID, details.getOrDefault("client_id", details.get(OauthConstant.OAUTH_CLIENT_ID)));
info.put(OauthConstant.OAUTH_CLIENT_SECRET, details.getOrDefault("client_secret", details.get(OauthConstant.OAUTH_CLIENT_SECRET)));
}
// handle refresh token embedding
OAuth2RefreshToken refreshToken = result.getRefreshToken();
if (refreshToken != null) {
DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken);
encodedRefreshToken.setValue(refreshToken.getValue());
encodedRefreshToken.setExpiration(null);
try {
Map
claims = objectMapper.parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
if (claims.containsKey(TOKEN_ID)) {
encodedRefreshToken.setValue(claims.get(TOKEN_ID).toString());
}
} catch (IllegalArgumentException e) { }
Map
refreshTokenInfo = new LinkedHashMap<>(accessToken.getAdditionalInformation());
refreshTokenInfo.put(TOKEN_ID, encodedRefreshToken.getValue());
if (details != null && !details.isEmpty()) {
refreshTokenInfo.put(OauthConstant.OAUTH_CLIENT_ID, details.getOrDefault("client_id", details.get(OauthConstant.OAUTH_CLIENT_ID)));
refreshTokenInfo.put(OauthConstant.OAUTH_CLIENT_SECRET, details.getOrDefault("client_secret", details.get(OauthConstant.OAUTH_CLIENT_SECRET)));
}
refreshTokenInfo.put(ACCESS_TOKEN_ID, tokenId);
encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(encode(encodedRefreshToken, authentication));
if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration();
encodedRefreshToken.setExpiration(expiration);
token = new DefaultExpiringOAuth2RefreshToken(encode(encodedRefreshToken, authentication), expiration);
}
result.setRefreshToken(token);
info.put(OauthConstant.OAUTH_REFRESH_TOKEN, token.getValue());
}
result.setAdditionalInformation(info);
result.setValue(encode(result, authentication));
return result;
}
}The refresh process is abstracted through the TokenRefreshExecutor interface, which defines shouldRefresh() and refresh() methods.
public interface TokenRefreshExecutor {
/** Execute refresh and return new access token */
String refresh() throws Exception;
/** Whether a refresh is required */
boolean shouldRefresh();
void setTokenStore(TokenStore tokenStore);
void setAccessToken(OAuth2AccessToken accessToken);
void setClientService(ClientDetailsService clientService);
}The JWT‑specific executor ( OauthJwtTokenRefreshExecutor ) checks if the current token is expired and, if so, sends an HTTP POST to /oauth/token with the stored client_id , client_secret and refresh_token . The new token is written to the response headers so the front‑end can update silently.
public class OauthJwtTokenRefreshExecutor extends AbstractTokenRefreshExecutor {
@Override
public boolean shouldRefresh() {
return getAccessToken() != null && getAccessToken().isExpired();
}
@Override
public String refresh() throws Exception {
HttpServletRequest request = ServletUtil.getRequest();
HttpServletResponse response = ServletUtil.getResponse();
MultiValueMap
parameters = new LinkedMultiValueMap<>();
parameters.add("client_id", TokenUtil.getStringInfo(getAccessToken(), OauthConstant.OAUTH_CLIENT_ID));
parameters.add("client_secret", TokenUtil.getStringInfo(getAccessToken(), OauthConstant.OAUTH_CLIENT_SECRET));
parameters.add("refresh_token", TokenUtil.getStringInfo(getAccessToken(), OauthConstant.OAUTH_REFRESH_TOKEN));
parameters.add("grant_type", "refresh_token");
Map result = RestfulUtil.post(getOauthTokenUrl(request), parameters);
if (result == null || result.isEmpty() || !result.containsKey("access_token")) {
throw new IllegalStateException("refresh token failed.");
}
String accessToken = result.get("access_token").toString();
OAuth2AccessToken oAuth2AccessToken = getTokenStore().readAccessToken(accessToken);
OAuth2Authentication auth2Authentication = getTokenStore().readAuthentication(oAuth2AccessToken);
SecurityContextHolder.getContext().setAuthentication(auth2Authentication);
response.setHeader("event", "token-refreshed");
response.setHeader("access_token", accessToken);
return accessToken;
}
private String getOauthTokenUrl(HttpServletRequest request) {
return String.format("%s://%s:%s%s%s",
request.getScheme(),
request.getLocalAddr(),
request.getLocalPort(),
Strings.isNotBlank(request.getContextPath()) ? "/" + request.getContextPath() : "",
"/oauth/token");
}
}The author also creates a custom token service ( OauthTokenServices ) that overrides loadAuthentication to invoke the executor before delegating to the superclass:
public class OauthTokenServices extends DefaultTokenServices {
private TokenStore tokenStore;
private TokenRefreshExecutor executor;
public OauthTokenServices(TokenStore tokenStore, TokenRefreshExecutor executor) {
super.setTokenStore(tokenStore);
this.tokenStore = tokenStore;
this.executor = executor;
}
@Override
public OAuth2Authentication loadAuthentication(String accessTokenValue) throws AuthenticationException, InvalidTokenException {
OAuth2AccessToken accessToken = tokenStore.readAccessToken(accessTokenValue);
executor.setAccessToken(accessToken);
if (executor.shouldRefresh()) {
try {
String newAccessTokenValue = executor.refresh();
if (!newAccessTokenValue.equals(accessTokenValue)) {
tokenStore.removeAccessToken(accessToken);
}
accessTokenValue = newAccessTokenValue;
} catch (Exception e) {
logger.error("token refresh failed.", e);
}
}
return super.loadAuthentication(accessTokenValue);
}
}Bean configuration ( TokenConfig ) registers the JWT token store, the custom access‑token converter, the JWT‑specific TokenRefreshExecutor , and the customized AuthorizationServerTokenServices that uses the executor.
@Configuration
public class TokenConfig {
@Bean
public TokenStore tokenStore(AccessTokenConverter converter) {
return new JwtTokenStore((JwtAccessTokenConverter) converter);
}
@Bean
public AccessTokenConverter accessTokenConverter(SecurityUserService userService) {
JwtAccessTokenConverter accessTokenConverter = new OauthJwtAccessTokenConverter(userService);
accessTokenConverter.setSigningKey("sign_key");
return accessTokenConverter;
}
@Bean
public TokenRefreshExecutor tokenRefreshExecutor(TokenStore tokenStore, ClientDetailsService clientService) {
TokenRefreshExecutor executor = new OauthJwtTokenRefreshExecutor();
executor.setTokenStore(tokenStore);
executor.setClientService(clientService);
return executor;
}
@Bean
public AuthorizationServerTokenServices tokenServices(TokenStore tokenstore, AccessTokenConverter accessTokenConverter,
ClientDetailsService clientService, TokenRefreshExecutor executor) {
OauthTokenServices tokenServices = new OauthTokenServices(tokenstore, executor);
tokenServices.setTokenEnhancer((TokenEnhancer) accessTokenConverter);
tokenServices.setSupportRefreshToken(true);
tokenServices.setClientDetailsService(clientService);
tokenServices.setReuseRefreshToken(true);
return tokenServices;
}
}The authorization server configuration wires these beans into the OAuth2 endpoints and security settings.
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired private AuthenticationManager manager;
@Autowired private SecurityUserService userService;
@Autowired private TokenStore tokenStore;
@Autowired private AccessTokenConverter tokenConverter;
@Autowired private AuthorizationServerTokenServices tokenServices;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore)
.authenticationManager(manager)
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
.userDetailsService(userService)
.accessTokenConverter(tokenConverter)
.tokenServices(tokenServices);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService());
}
public ClientDetailsService clientDetailsService() {
return new OauthClientService();
}
}On the front‑end, an Axios response interceptor checks the custom event header; when it equals token-refreshed , the interceptor updates the stored token silently:
service.interceptors.response.use(res => {
if (res.headers['event'] && "token-refreshed" === res.headers['event']) {
setToken(res.headers['access_token']);
store.commit('SET_TOKEN', res.headers['access_token']);
}
// ... other logic ...
});For in‑memory tokens, a simpler executor ( OauthTokenRefreshExecutor ) merely extends the token’s expiration time without changing its value.
public class OauthTokenRefreshExecutor extends AbstractTokenRefreshExecutor {
private int accessTokenValiditySeconds = 60 * 60 * 12; // 12 hours default
@Override
public boolean shouldRefresh() {
return getAccessToken() != null && !getAccessToken().isExpired();
}
@Override
public String refresh() {
int seconds = accessTokenValiditySeconds;
if (getClientService() != null) {
OAuth2Authentication auth = getTokenStore().readAuthentication(getAccessToken());
String clientId = auth.getOAuth2Request().getClientId();
ClientDetails client = getClientService().loadClientByClientId(clientId);
seconds = client.getAccessTokenValiditySeconds();
}
((DefaultOAuth2AccessToken) getAccessToken()).setExpiration(new Date(System.currentTimeMillis() + seconds * 1000L));
return getAccessToken().getValue();
}
}Finally, the article notes that token revocation remains an open problem and invites readers to share better solutions.
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.