spring-projects/spring-security · error · BadCredentialsException

DigestAuthenticationFilter.nonceNotTwoTokens

DigestAuthenticationFilter.nonceNotTwoTokens

Error message

Nonce should have yielded two tokens but was {0}

What it means

After Base64-decoding, the nonce plaintext must have the form 'expiryTime:signature' (two colon-separated tokens). validateAndDecode throws this BadCredentialsException when splitting the decoded nonce on ':' does not yield exactly two tokens, i.e. the nonce payload structure isn't what the server's entry point produced.

Solutions

  1. Obtain nonces only from this server's DigestAuthenticationEntryPoint via a fresh 401 challenge; never fabricate or reuse nonces from another app.
  2. Check for multiple digest-auth apps behind the same host and make clients target the correct origin / consume each app's own challenge.
  3. Verify all server instances behind the load balancer run a consistent Spring Security version and identical entry point configuration.
  4. Decode the nonce client-side (base64 -> 'expiry:md5sig') to sanity-check the shape before sending.

Example fix

// before (hand-made nonce)
String nonce = Base64.getEncoder().encodeToString("12345".getBytes());
// after: request a challenge and use the server-issued nonce
String nonce = extractNonce(authConn.getHeaderField("WWW-Authenticate")); // expiry:signature format
Defensive patterns

Strategy: validation

Validate before calling

String plain = new String(java.util.Base64.getDecoder().decode(nonce.getBytes(StandardCharsets.UTF_8)));
if (plain.split(":", -1).length != 2) {
    throw new IllegalStateException("nonce payload must be '<expiry>:<signature>'; request a fresh challenge");
}

Type guard

boolean hasNonceShape(String decodedNonce) {
    return decodedNonce != null && decodedNonce.split(":", -1).length == 2;
}

Try / catch

try {
    chain.doFilter(request, response);
} catch (BadCredentialsException e) {
    if (e.getMessage().contains("should have yielded two tokens")) {
        response.sendError(401, "Unrecognized nonce format; re-authenticate from the server challenge");
    }
}

Prevention

When it happens

Trigger: Base64-decoding succeeds but the plaintext contains zero, one, or 3+ colon-separated segments — e.g. a nonce fabricated by the client, a nonce issued by a different application/server sharing the Base64 shape but not the format, or a corrupted/transformed nonce that happens to be valid Base64.

Common situations: Two different services both doing digest auth behind one domain and the client replaying a nonce from the wrong service; manual testing with arbitrary Base64 strings as nonces; a load balancer pool running mismatched application versions with different nonce formats.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of spring-projects/spring-security@96852e8860 (2026-09-10). Data as JSON: /api/errors/af4139c263889826. Report an issue: GitHub.

Appendix: source

Thrown at web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthenticationFilter.java:394

						"DigestAuthenticationFilter.incorrectRealm", new Object[] { this.realm, expectedRealm },
						"Response realm name '{0}' does not match system realm name of '{1}'"));
			}
			// Check nonce was Base64 encoded (as sent by DigestAuthenticationEntryPoint)
			final byte[] nonceBytes;
			try {
				nonceBytes = Base64.getDecoder().decode(this.nonce.getBytes());
			}
			catch (IllegalArgumentException ex) {
				throw new BadCredentialsException(
						DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceEncoding",
								new Object[] { this.nonce }, "Nonce is not encoded in Base64; received nonce {0}"));
			}
			// Decode nonce from Base64 format of nonce is: base64(expirationTime + ":" +
			// md5Hex(expirationTime + ":" + key))
			String nonceAsPlainText = new String(nonceBytes);
			String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
			if (nonceTokens.length != 2) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.nonceNotTwoTokens", new Object[] { nonceAsPlainText },
						"Nonce should have yielded two tokens but was {0}"));
			}
			// Extract expiry time from nonce
			try {
				this.nonceExpiryTime = Long.valueOf(nonceTokens[0]);
			}
			catch (NumberFormatException nfe) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.nonceNotNumeric", new Object[] { nonceAsPlainText },
						"Nonce token should have yielded a numeric first token, but was {0}"));
			}
			// Check signature of nonce matches this expiry time
			String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
			if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
						"Nonce token compromised {0}"));

View on GitHub (pinned to 96852e8860)