spring-projects/spring-security · error · BadCredentialsException
Unable to authenticate the PublicKeyCredential. No…
Error message
Unable to authenticate the PublicKeyCredential. No PublicKeyCredentialRequestOptions found.
What it means
WebAuthnAuthenticationFilter.attemptAuthentication() loads the stored PublicKeyCredentialRequestOptions for the current request before validating the credential; if the repository returns null it throws this BadCredentialsException. It means the server has no record of a challenge for this authentication attempt.
Solutions
- Ensure the client first calls the endpoint that generates and stores PublicKeyCredentialRequestOptions, then immediately POSTs the assertion.
- Use a shared/persistent PublicKeyCredentialRequestOptionsRepository (e.g. HTTP-session or database-backed) across nodes instead of per-instance in-memory storage.
- Check the request carries the identifying cookie/session data so load() can find the stored options; re-obtain a fresh challenge after every consumed attempt.
Example fix
// before
@Bean
PublicKeyCredentialRequestOptionsRepository requestOptionsRepository() {
return new InMemoryPublicKeyCredentialRequestOptionsRepository(); // lost on restart / across nodes
}
// after
@Bean
PublicKeyCredentialRequestOptionsRepository requestOptionsRepository() {
return new HttpSessionPublicKeyCredentialRequestOptionsRepository(); // shared per user session
} Defensive patterns
Strategy: try-catch
Validate before calling
// client: fetch assertion options first and fail fast if unavailable
const opts = await fetch('/webauthn/options', { credentials: 'include' });
if (!opts.ok) throw new Error('No challenge issued — cannot authenticate');
Try / catch
try {
authenticationManager.authenticate(token);
} catch (BadCredentialsException e) {
if (e.getMessage().contains("No PublicKeyCredentialRequestOptions")) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Challenge expired; request a new one");
}
throw e;
}
Prevention
- Always issue and store PublicKeyCredentialRequestOptions before accepting assertions
- Use HttpSession-backed or persistent options repositories in clustered deployments
- Remember each challenge is single-use: the filter nulls it after an attempt — always fetch a fresh one before retrying
- Ensure cookies/session identifiers travel with both the options request and the assertion POST
When it happens
Trigger: POSTing an assertion when requestOptionsRepository.load(request) returns null: the challenge was never created, already consumed (saved as null after a prior attempt), expired/cleared from the repository, or the request hits a different server/session than the one that issued the challenge.
Common situations: Application restarted between challenge issuance and assertion (in-memory repository lost); load-balanced deployment without sticky sessions/shared repository; client retries the POST and the filter's save(request, response, null) already cleared the options; missing step that creates the options (WebAuthnRequestsURLEndpoint) beforehand.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- <ex.getMessage()>
- Unable to authenticate the PublicKeyCredential
- Credential with id already exists
- Unknown Callback
- Unused placeholders in template
AI-assisted analysis of spring-projects/spring-security@96852e8860 (2026-09-10).
Data as JSON: /api/errors/0b036d8c778535b7.
Report an issue: GitHub.
Appendix: source
Thrown at webauthn/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationFilter.java:114
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
ServletServerHttpRequest httpRequest = new ServletServerHttpRequest(request);
ResolvableType resolvableType = ResolvableType.forClassWithGenerics(PublicKeyCredential.class,
AuthenticatorAssertionResponse.class);
PublicKeyCredential<AuthenticatorAssertionResponse> publicKeyCredential = null;
try {
publicKeyCredential = (PublicKeyCredential<AuthenticatorAssertionResponse>) this.converter
.read(resolvableType, httpRequest, null);
}
catch (Exception ex) {
throw new BadCredentialsException("Unable to authenticate the PublicKeyCredential", ex);
}
PublicKeyCredentialRequestOptions requestOptions = this.requestOptionsRepository.load(request);
if (requestOptions == null) {
throw new BadCredentialsException(
"Unable to authenticate the PublicKeyCredential. No PublicKeyCredentialRequestOptions found.");
}
this.requestOptionsRepository.save(request, response, null);
RelyingPartyAuthenticationRequest authenticationRequest = new RelyingPartyAuthenticationRequest(requestOptions,
publicKeyCredential);
WebAuthnAuthenticationRequestToken token = new WebAuthnAuthenticationRequestToken(authenticationRequest);
return getAuthenticationManager().authenticate(token);
}
/**
* Sets the {@link GenericHttpMessageConverter} to use for writing
* {@code PublicKeyCredential<AuthenticatorAssertionResponse>} to the response. The
* default is @{code MappingJackson2HttpMessageConverter}
* @param converter the {@link GenericHttpMessageConverter} to use. Cannot be null.
* @deprecated use {@link #setConverter(SmartHttpMessageConverter)}
*/
@Deprecated(forRemoval = true, since = "7.0")
public void setConverter(GenericHttpMessageConverter<Object> converter) {View on GitHub (pinned to 96852e8860)