VYPR
Medium severity4.8NVD Advisory· Published Apr 21, 2026· Updated May 1, 2026

CVE-2026-22751

CVE-2026-22751

Description

Vulnerability in Spring Spring Security. Applications that explicitly configure One-Time Token login with JdbcOneTimeTokenService are vulnerable to a Time-of-check Time-of-use (TOCTOU) race condition. This issue affects Spring Security: from 6.4.0 through 6.4.15, from 6.5.0 through 6.5.9, from 7.0.0 through 7.0.4.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.springframework.security:spring-security-coreMaven
>= 6.5.0, < 6.5.106.5.10
org.springframework.security:spring-security-coreMaven
>= 7.0.3, < 7.0.57.0.5
org.springframework.security:spring-security-coreMaven
>= 6.4.0, <= 6.4.13

Affected products

1

Patches

2
163772775036

Merge branch '6.5.x' into 7.0.x

8 files changed · +174 19
  • core/src/main/java/org/springframework/security/authentication/dao/AbstractUserDetailsAuthenticationProvider.java+39 4 modified
    @@ -97,6 +97,8 @@ public abstract class AbstractUserDetailsAuthenticationProvider
     
     	private UserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks();
     
    +	private boolean alwaysPerformAdditionalChecksOnUser = true;
    +
     	private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
     
     	private static final String AUTHORITY = FactorGrantedAuthority.PASSWORD_AUTHORITY;
    @@ -154,8 +156,7 @@ public Authentication authenticate(Authentication authentication) throws Authent
     			Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
     		}
     		try {
    -			this.preAuthenticationChecks.check(user);
    -			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    +			performPreCheck(user, (UsernamePasswordAuthenticationToken) authentication);
     		}
     		catch (AuthenticationException ex) {
     			if (!cacheWasUsed) {
    @@ -165,8 +166,7 @@ public Authentication authenticate(Authentication authentication) throws Authent
     			// we're using latest data (i.e. not from the cache)
     			cacheWasUsed = false;
     			user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
    -			this.preAuthenticationChecks.check(user);
    -			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    +			performPreCheck(user, (UsernamePasswordAuthenticationToken) authentication);
     		}
     		this.postAuthenticationChecks.check(user);
     		if (!cacheWasUsed) {
    @@ -179,6 +179,25 @@ public Authentication authenticate(Authentication authentication) throws Authent
     		return createSuccessAuthentication(principalToReturn, authentication, user);
     	}
     
    +	private void performPreCheck(UserDetails user, UsernamePasswordAuthenticationToken authentication) {
    +		try {
    +			this.preAuthenticationChecks.check(user);
    +		}
    +		catch (AuthenticationException ex) {
    +			if (!this.alwaysPerformAdditionalChecksOnUser) {
    +				throw ex;
    +			}
    +			try {
    +				additionalAuthenticationChecks(user, authentication);
    +			}
    +			catch (AuthenticationException ignored) {
    +				// preserve the original failed check
    +			}
    +			throw ex;
    +		}
    +		additionalAuthenticationChecks(user, authentication);
    +	}
    +
     	private String determineUsername(Authentication authentication) {
     		return (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
     	}
    @@ -324,6 +343,22 @@ public void setPostAuthenticationChecks(UserDetailsChecker postAuthenticationChe
     		this.postAuthenticationChecks = postAuthenticationChecks;
     	}
     
    +	/**
    +	 * Set whether to always perform the additional checks on the user, even if the
    +	 * pre-authentication checks fail. This is useful to ensure that regardless of the
    +	 * state of the user account, authentication takes the same amount of time to
    +	 * complete.
    +	 *
    +	 * <p>
    +	 * For applications that rely on the additional checks running only once should set
    +	 * this value to {@code false}
    +	 * @param alwaysPerformAdditionalChecksOnUser
    +	 * @since 5.7.23
    +	 */
    +	public void setAlwaysPerformAdditionalChecksOnUser(boolean alwaysPerformAdditionalChecksOnUser) {
    +		this.alwaysPerformAdditionalChecksOnUser = alwaysPerformAdditionalChecksOnUser;
    +	}
    +
     	public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) {
     		this.authoritiesMapper = authoritiesMapper;
     	}
    
  • core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java+5 3 modified
    @@ -153,7 +153,9 @@ private void insertOneTimeToken(OneTimeToken oneTimeToken) {
     			return null;
     		}
     		OneTimeToken token = tokens.get(0);
    -		deleteOneTimeToken(token);
    +		if (deleteOneTimeToken(token) == 0) {
    +			return null;
    +		}
     		if (isExpired(token)) {
     			return null;
     		}
    @@ -171,11 +173,11 @@ private List<OneTimeToken> selectOneTimeToken(OneTimeTokenAuthenticationToken au
     		return this.jdbcOperations.query(SELECT_ONE_TIME_TOKEN_SQL, pss, this.oneTimeTokenRowMapper);
     	}
     
    -	private void deleteOneTimeToken(OneTimeToken oneTimeToken) {
    +	private int deleteOneTimeToken(OneTimeToken oneTimeToken) {
     		List<SqlParameterValue> parameters = List
     			.of(new SqlParameterValue(Types.VARCHAR, oneTimeToken.getTokenValue()));
     		PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
    -		this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss);
    +		return this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss);
     	}
     
     	private @Nullable ThreadPoolTaskScheduler createTaskScheduler(String cleanupCron) {
    
  • core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java+42 6 modified
    @@ -21,6 +21,7 @@
     import java.util.List;
     
     import org.junit.jupiter.api.Test;
    +import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
     
     import org.springframework.cache.Cache;
     import org.springframework.dao.DataRetrievalFailureException;
    @@ -44,6 +45,7 @@
     import org.springframework.security.core.userdetails.PasswordEncodedUser;
     import org.springframework.security.core.userdetails.User;
     import org.springframework.security.core.userdetails.UserDetails;
    +import org.springframework.security.core.userdetails.UserDetailsChecker;
     import org.springframework.security.core.userdetails.UserDetailsPasswordService;
     import org.springframework.security.core.userdetails.UserDetailsService;
     import org.springframework.security.core.userdetails.UsernameNotFoundException;
    @@ -64,6 +66,7 @@
     import static org.mockito.ArgumentMatchers.eq;
     import static org.mockito.ArgumentMatchers.isA;
     import static org.mockito.BDDMockito.given;
    +import static org.mockito.BDDMockito.willThrow;
     import static org.mockito.Mockito.mock;
     import static org.mockito.Mockito.times;
     import static org.mockito.Mockito.verify;
    @@ -422,12 +425,10 @@ public void constructWhenPasswordEncoderProvidedThenSets() {
     		assertThat(daoAuthenticationProvider.getPasswordEncoder()).isSameAs(NoOpPasswordEncoder.getInstance());
     	}
     
    -	/**
    -	 * This is an explicit test for SEC-2056. It is intentionally ignored since this test
    -	 * is not deterministic and {@link #testUserNotFoundEncodesPassword()} ensures that
    -	 * SEC-2056 is fixed.
    -	 */
    -	public void IGNOREtestSec2056() {
    +	// SEC-2056
    +	@Test
    +	@EnabledIfSystemProperty(named = "spring.security.timing-tests", matches = "true")
    +	public void testSec2056() {
     		UsernamePasswordAuthenticationToken foundUser = UsernamePasswordAuthenticationToken.unauthenticated("rod",
     				"koala");
     		UsernamePasswordAuthenticationToken notFoundUser = UsernamePasswordAuthenticationToken
    @@ -460,6 +461,41 @@ public void IGNOREtestSec2056() {
     			.isTrue();
     	}
     
    +	// related to SEC-2056
    +	@Test
    +	@EnabledIfSystemProperty(named = "spring.security.timing-tests", matches = "true")
    +	public void testDisabledUserTiming() {
    +		UsernamePasswordAuthenticationToken user = UsernamePasswordAuthenticationToken.unauthenticated("rod", "koala");
    +		PasswordEncoder encoder = new BCryptPasswordEncoder();
    +		DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    +		provider.setPasswordEncoder(encoder);
    +		MockUserDetailsServiceUserRod users = new MockUserDetailsServiceUserRod();
    +		users.password = encoder.encode((CharSequence) user.getCredentials());
    +		provider.setUserDetailsService(users);
    +		int sampleSize = 100;
    +		List<Long> enabledTimes = new ArrayList<>(sampleSize);
    +		for (int i = 0; i < sampleSize; i++) {
    +			long start = System.currentTimeMillis();
    +			provider.authenticate(user);
    +			enabledTimes.add(System.currentTimeMillis() - start);
    +		}
    +		UserDetailsChecker preChecks = mock(UserDetailsChecker.class);
    +		willThrow(new DisabledException("User is disabled")).given(preChecks).check(any(UserDetails.class));
    +		provider.setPreAuthenticationChecks(preChecks);
    +		List<Long> disabledTimes = new ArrayList<>(sampleSize);
    +		for (int i = 0; i < sampleSize; i++) {
    +			long start = System.currentTimeMillis();
    +			assertThatExceptionOfType(DisabledException.class).isThrownBy(() -> provider.authenticate(user));
    +			disabledTimes.add(System.currentTimeMillis() - start);
    +		}
    +		double enabledAvg = avg(enabledTimes);
    +		double disabledAvg = avg(disabledTimes);
    +		assertThat(Math.abs(disabledAvg - enabledAvg) <= 3)
    +			.withFailMessage("Disabled user average " + disabledAvg + " should be within 3ms of enabled user average "
    +					+ enabledAvg)
    +			.isTrue();
    +	}
    +
     	private double avg(List<Long> counts) {
     		return counts.stream().mapToLong(Long::longValue).average().orElse(0);
     	}
    
  • core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java+26 0 modified
    @@ -21,19 +21,24 @@
     import java.time.Instant;
     import java.time.ZoneOffset;
     import java.time.temporal.ChronoUnit;
    +import java.util.List;
     
     import org.junit.jupiter.api.AfterEach;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.Test;
    +import org.mockito.ArgumentMatchers;
     
     import org.springframework.jdbc.core.JdbcOperations;
     import org.springframework.jdbc.core.JdbcTemplate;
    +import org.springframework.jdbc.core.PreparedStatementSetter;
    +import org.springframework.jdbc.core.RowMapper;
     import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
     import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
     import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
     
     import static org.assertj.core.api.Assertions.assertThat;
     import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
    +import static org.mockito.ArgumentMatchers.any;
     import static org.mockito.BDDMockito.given;
     import static org.mockito.Mockito.mock;
     
    @@ -145,6 +150,27 @@ void consumeWhenTokenDoesNotExistsThenReturnNull() {
     		assertThat(consumedOneTimeToken).isNull();
     	}
     
    +	@Test
    +	void consumeWhenTokenIsDeletedConcurrentlyThenReturnNull() throws Exception {
    +		// Simulates a concurrent consume: SELECT finds the token but DELETE affects
    +		// 0 rows because another caller already consumed it.
    +		JdbcOperations jdbcOperations = mock(JdbcOperations.class);
    +		Instant notExpired = Instant.now().plus(5, ChronoUnit.MINUTES);
    +		OneTimeToken token = new DefaultOneTimeToken(TOKEN_VALUE, USERNAME, notExpired);
    +		given(jdbcOperations.query(any(String.class), any(PreparedStatementSetter.class),
    +				ArgumentMatchers.<RowMapper<OneTimeToken>>any()))
    +			.willReturn(List.of(token));
    +		given(jdbcOperations.update(any(String.class), any(PreparedStatementSetter.class))).willReturn(0);
    +		JdbcOneTimeTokenService service = new JdbcOneTimeTokenService(jdbcOperations);
    +		try {
    +			OneTimeToken consumed = service.consume(new OneTimeTokenAuthenticationToken(TOKEN_VALUE));
    +			assertThat(consumed).isNull();
    +		}
    +		finally {
    +			service.destroy();
    +		}
    +	}
    +
     	@Test
     	void consumeWhenTokenIsExpiredThenReturnNull() {
     		GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
    
  • oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java+13 2 modified
    @@ -232,7 +232,8 @@ public static JwkSetUriJwtDecoderBuilder withIssuerLocation(String issuer) {
     				.getConfigurationForIssuerLocation(issuer, rest);
     			JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, issuer);
     			return configuration.get("jwks_uri").toString();
    -		}, JwtDecoderProviderConfigurationUtils::getJWSAlgorithms);
    +		}, JwtDecoderProviderConfigurationUtils::getJWSAlgorithms)
    +			.validator(JwtValidators.createDefaultWithIssuer(issuer));
     	}
     
     	/**
    @@ -301,6 +302,8 @@ public static final class JwkSetUriJwtDecoderBuilder {
     
     		private Consumer<ConfigurableJWTProcessor<SecurityContext>> jwtProcessorCustomizer;
     
    +		private OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
    +
     		private JwkSetUriJwtDecoderBuilder(String jwkSetUri) {
     			Assert.hasText(jwkSetUri, "jwkSetUri cannot be empty");
     			this.jwkSetUri = (rest) -> jwkSetUri;
    @@ -441,6 +444,12 @@ public JwkSetUriJwtDecoderBuilder jwtProcessorCustomizer(
     			return this;
     		}
     
    +		JwkSetUriJwtDecoderBuilder validator(OAuth2TokenValidator<Jwt> validator) {
    +			Assert.notNull(validator, "validator cannot be null");
    +			this.validator = validator;
    +			return this;
    +		}
    +
     		JWSKeySelector<SecurityContext> jwsKeySelector(JWKSource<SecurityContext> jwkSource) {
     			if (this.signatureAlgorithms.isEmpty()) {
     				return new JWSVerificationKeySelector<>(this.defaultAlgorithms.apply(jwkSource), jwkSource);
    @@ -479,7 +488,9 @@ JWTProcessor<SecurityContext> processor() {
     		 * @return the configured {@link NimbusJwtDecoder}
     		 */
     		public NimbusJwtDecoder build() {
    -			return new NimbusJwtDecoder(processor());
    +			NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor());
    +			decoder.setJwtValidator(this.validator);
    +			return decoder;
     		}
     
     		private static final class SpringJWKSource<C extends SecurityContext> implements JWKSetSource<C> {
    
  • oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java+12 2 modified
    @@ -241,7 +241,8 @@ public static JwkSetUriReactiveJwtDecoderBuilder withIssuerLocation(String issue
     						}
     						return Mono.just(configuration.get("jwks_uri").toString());
     					}),
    -				ReactiveJwtDecoderProviderConfigurationUtils::getJWSAlgorithms);
    +				ReactiveJwtDecoderProviderConfigurationUtils::getJWSAlgorithms)
    +			.validator(JwtValidators.createDefaultWithIssuer(issuer));
     	}
     
     	/**
    @@ -332,6 +333,8 @@ public static final class JwkSetUriReactiveJwtDecoderBuilder {
     
     		private BiFunction<ReactiveRemoteJWKSource, ConfigurableJWTProcessor<JWKSecurityContext>, Mono<ConfigurableJWTProcessor<JWKSecurityContext>>> jwtProcessorCustomizer;
     
    +		private OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
    +
     		private JwkSetUriReactiveJwtDecoderBuilder(String jwkSetUri) {
     			Assert.hasText(jwkSetUri, "jwkSetUri cannot be empty");
     			this.jwkSetUri = (web) -> Mono.just(jwkSetUri);
    @@ -456,6 +459,11 @@ public JwkSetUriReactiveJwtDecoderBuilder jwtProcessorCustomizer(
     			return this;
     		}
     
    +		JwkSetUriReactiveJwtDecoderBuilder validator(OAuth2TokenValidator<Jwt> validator) {
    +			this.validator = validator;
    +			return this;
    +		}
    +
     		JwkSetUriReactiveJwtDecoderBuilder jwtProcessorCustomizer(
     				BiFunction<ReactiveRemoteJWKSource, ConfigurableJWTProcessor<JWKSecurityContext>, Mono<ConfigurableJWTProcessor<JWKSecurityContext>>> jwtProcessorCustomizer) {
     			Assert.notNull(jwtProcessorCustomizer, "jwtProcessorCustomizer cannot be null");
    @@ -468,7 +476,9 @@ JwkSetUriReactiveJwtDecoderBuilder jwtProcessorCustomizer(
     		 * @return the configured {@link NimbusReactiveJwtDecoder}
     		 */
     		public NimbusReactiveJwtDecoder build() {
    -			return new NimbusReactiveJwtDecoder(processor());
    +			NimbusReactiveJwtDecoder decoder = new NimbusReactiveJwtDecoder(processor());
    +			decoder.setJwtValidator(this.validator);
    +			return decoder;
     		}
     
     		Mono<JWSKeySelector<JWKSecurityContext>> jwsKeySelector(ReactiveRemoteJWKSource source) {
    
  • oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java+16 1 modified
    @@ -332,7 +332,10 @@ public void decodeWhenIssuerLocationThenOk() {
     			.willReturn(new ResponseEntity<>(Map.of("issuer", issuer, "jwks_uri", issuer + "/jwks"), HttpStatus.OK));
     		given(restOperations.exchange(any(RequestEntity.class), eq(String.class)))
     			.willReturn(new ResponseEntity<>(JWK_SET, HttpStatus.OK));
    -		JwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(restOperations).build();
    +		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
    +			.restOperations(restOperations)
    +			.build();
    +		jwtDecoder.setJwtValidator(JwtValidators.createDefault());
     		Jwt jwt = jwtDecoder.decode(SIGNED_JWT);
     		assertThat(jwt.hasClaim(JwtClaimNames.EXP)).isNotNull();
     	}
    @@ -350,6 +353,18 @@ public void decodeWhenDiscoverJwsAlgorithmsThenOk() {
     		assertThat(jwt.hasClaim(JwtClaimNames.EXP)).isNotNull();
     	}
     
    +	@Test
    +	public void decodeWhenIssuerLocationThenRejectsMismatchingIssuers() {
    +		String issuer = "https://example.org/wrong-issuer";
    +		RestOperations restOperations = mock(RestOperations.class);
    +		given(restOperations.exchange(any(RequestEntity.class), any(ParameterizedTypeReference.class)))
    +			.willReturn(new ResponseEntity<>(Map.of("issuer", issuer, "jwks_uri", issuer + "/jwks"), HttpStatus.OK));
    +		given(restOperations.exchange(any(RequestEntity.class), eq(String.class)))
    +			.willReturn(new ResponseEntity<>(JWK_SET, HttpStatus.OK));
    +		JwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(restOperations).build();
    +		assertThatExceptionOfType(JwtValidationException.class).isThrownBy(() -> jwtDecoder.decode(SIGNED_JWT));
    +	}
    +
     	@Test
     	public void withJwkSetUriWhenNullOrEmptyThenThrowsException() {
     		// @formatter:off
    
  • oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java+21 1 modified
    @@ -617,13 +617,33 @@ public void decodeWhenIssuerLocationThenOk() {
     		given(responseSpec.bodyToMono(any(ParameterizedTypeReference.class)))
     			.willReturn(Mono.just(Map.of("issuer", issuer, "jwks_uri", issuer + "/jwks")));
     		given(spec.retrieve()).willReturn(responseSpec);
    -		ReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer)
    +		NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer)
     			.webClient(webClient)
     			.build();
    +		jwtDecoder.setJwtValidator(JwtValidators.createDefault());
     		Jwt jwt = jwtDecoder.decode(this.messageReadToken).block();
     		assertThat(jwt.hasClaim(JwtClaimNames.EXP)).isNotNull();
     	}
     
    +	@Test
    +	public void decodeWhenIssuerLocationThenRejectsMismatchingIssuers() {
    +		String issuer = "https://example.org/wrong-issuer";
    +		WebClient real = WebClient.builder().build();
    +		WebClient.RequestHeadersUriSpec spec = spy(real.get());
    +		WebClient webClient = spy(WebClient.class);
    +		given(webClient.get()).willReturn(spec);
    +		WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class);
    +		given(responseSpec.bodyToMono(String.class)).willReturn(Mono.just(this.jwkSet));
    +		given(responseSpec.bodyToMono(any(ParameterizedTypeReference.class)))
    +			.willReturn(Mono.just(Map.of("issuer", issuer, "jwks_uri", issuer + "/jwks")));
    +		given(spec.retrieve()).willReturn(responseSpec);
    +		ReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer)
    +			.webClient(webClient)
    +			.build();
    +		assertThatExceptionOfType(JwtValidationException.class)
    +			.isThrownBy(() -> jwtDecoder.decode(this.messageReadToken).block());
    +	}
    +
     	@Test
     	public void jwsKeySelectorWhenNoAlgorithmThenReturnsRS256Selector() {
     		ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class);
    
4187af38b251

Verify token deletion in JdbcOneTimeTokenService

2 files changed · +31 3
  • core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java+5 3 modified
    @@ -152,7 +152,9 @@ public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken)
     			return null;
     		}
     		OneTimeToken token = tokens.get(0);
    -		deleteOneTimeToken(token);
    +		if (deleteOneTimeToken(token) == 0) {
    +			return null;
    +		}
     		if (isExpired(token)) {
     			return null;
     		}
    @@ -170,11 +172,11 @@ private List<OneTimeToken> selectOneTimeToken(OneTimeTokenAuthenticationToken au
     		return this.jdbcOperations.query(SELECT_ONE_TIME_TOKEN_SQL, pss, this.oneTimeTokenRowMapper);
     	}
     
    -	private void deleteOneTimeToken(OneTimeToken oneTimeToken) {
    +	private int deleteOneTimeToken(OneTimeToken oneTimeToken) {
     		List<SqlParameterValue> parameters = List
     			.of(new SqlParameterValue(Types.VARCHAR, oneTimeToken.getTokenValue()));
     		PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
    -		this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss);
    +		return this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss);
     	}
     
     	private ThreadPoolTaskScheduler createTaskScheduler(String cleanupCron) {
    
  • core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java+26 0 modified
    @@ -21,19 +21,24 @@
     import java.time.Instant;
     import java.time.ZoneOffset;
     import java.time.temporal.ChronoUnit;
    +import java.util.List;
     
     import org.junit.jupiter.api.AfterEach;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.Test;
    +import org.mockito.ArgumentMatchers;
     
     import org.springframework.jdbc.core.JdbcOperations;
     import org.springframework.jdbc.core.JdbcTemplate;
    +import org.springframework.jdbc.core.PreparedStatementSetter;
    +import org.springframework.jdbc.core.RowMapper;
     import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
     import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
     import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
     
     import static org.assertj.core.api.Assertions.assertThat;
     import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
    +import static org.mockito.ArgumentMatchers.any;
     import static org.mockito.BDDMockito.given;
     import static org.mockito.Mockito.mock;
     
    @@ -145,6 +150,27 @@ void consumeWhenTokenDoesNotExistsThenReturnNull() {
     		assertThat(consumedOneTimeToken).isNull();
     	}
     
    +	@Test
    +	void consumeWhenTokenIsDeletedConcurrentlyThenReturnNull() throws Exception {
    +		// Simulates a concurrent consume: SELECT finds the token but DELETE affects
    +		// 0 rows because another caller already consumed it.
    +		JdbcOperations jdbcOperations = mock(JdbcOperations.class);
    +		Instant notExpired = Instant.now().plus(5, ChronoUnit.MINUTES);
    +		OneTimeToken token = new DefaultOneTimeToken(TOKEN_VALUE, USERNAME, notExpired);
    +		given(jdbcOperations.query(any(String.class), any(PreparedStatementSetter.class),
    +				ArgumentMatchers.<RowMapper<OneTimeToken>>any()))
    +			.willReturn(List.of(token));
    +		given(jdbcOperations.update(any(String.class), any(PreparedStatementSetter.class))).willReturn(0);
    +		JdbcOneTimeTokenService service = new JdbcOneTimeTokenService(jdbcOperations);
    +		try {
    +			OneTimeToken consumed = service.consume(new OneTimeTokenAuthenticationToken(TOKEN_VALUE));
    +			assertThat(consumed).isNull();
    +		}
    +		finally {
    +			service.destroy();
    +		}
    +	}
    +
     	@Test
     	void consumeWhenTokenIsExpiredThenReturnNull() {
     		GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.