Skip to content

CWE-347: Improper Verification of Cryptographic Signature - Java

Overview

Java applications verify signatures in a few recurring ways, and each has its own way to get CWE-347 wrong. Low-level java.security.Signature code is misused when the boolean returned by signature.verify(sigBytes) is not checked, or when a broad catch (Exception e) around the verification call swallows a SignatureException and lets execution continue as if verification had succeeded. JWT verification with jjwt (io.jsonwebtoken) or Nimbus JOSE+JWT goes wrong when the token's own header is allowed to pick the algorithm or the key. XML signature verification with javax.xml.crypto.dsig is vulnerable to XML Signature Wrapping (XSW) when a successful XMLSignature.validate() is trusted without confirming that the signed <Reference> covers the exact element the application subsequently reads. Webhook and API signature checks are vulnerable when they use String.equals() or Arrays.equals() instead of a constant-time comparison.

The JWT half needs two separate statements, because the two libraries have not moved together and one of them is weaker than its API suggests. Measured on jjwt 0.12.6 and 0.13.0 and nimbus-jose-jwt 10.6:

  • Key confusion is closed by both. Neither will use an RSA public key to verify an HMAC signature, so the textbook forgery - re-sign an RS256 token as HS256 with the public key as the secret - is refused without any configuration on your part, as is alg: none. The forgery only lands where application code returns a genuine symmetric key because the header asked for one, which is what the key-locator pattern below does.
  • Algorithm pinning is closed by Nimbus and not by jjwt. SingleKeyJWSKeySelector(JWSAlgorithm.RS256, key) refuses an RS512 token; Jwts.parser().verifyWith(key) accepts it, because jjwt reads the algorithm from the token header and only checks that the key suits it. The same applies inside the symmetric family: an HS512 token verifies against an HS256 handler on the same secret. With jjwt, assert jws.getHeader().getAlgorithm() after parsing.

The safe replacements are: always check the return value of Signature.verify(); pin both the key and the algorithm (Nimbus's JWSKeySelector does both, jjwt's verifyWith() plus an explicit header assertion); after XML signature validation, confirm the signed reference resolves to the element being trusted; and use MessageDigest.isEqual() for any raw signature or HMAC comparison.

Common Vulnerable Patterns

Ignoring the Boolean Result of Signature.verify()

import java.security.PublicKey;
import java.security.Signature;

// VULNERABLE - the return value of verify() is discarded; any exception
// is swallowed, so an invalid signature never stops execution
public void processSignedRequest(byte[] data, byte[] signatureBytes, PublicKey publicKey) {
    try {
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initVerify(publicKey);
        sig.update(data);
        sig.verify(signatureBytes); // return value ignored
    } catch (Exception e) {
        // logged and ignored - verification failure does not stop processing
        log.warn("Signature check error", e);
    }
    handleRequest(data); // runs regardless of whether the signature was valid
}

Why this is vulnerable: Signature.verify() returns false for an invalid signature; it does not throw for a merely-invalid signature (only for structural errors like a malformed signature encoding). Code that does not check the returned boolean, or that catches every exception and continues instead of re-throwing or returning an error, treats an unverified request the same as a verified one.

JWT Algorithm Confusion via an Unpinned Key Locator

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Locator;
import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.security.PublicKey;

// VULNERABLE - the locator returns key material based on the token's own
// (unverified) header, so an attacker-chosen alg selects an attacker-usable key
Locator<Key> unsafeLocator = new Locator<Key>() {
    @Override
    public Key locate(Header header) {
        if ("HS256".equals(header.get("alg"))) {
            return new SecretKeySpec(rsaPublicKeyBytes, "HmacSHA256"); // reuses RSA public key bytes as an HMAC key
        }
        return rsaPublicKey;
    }
};

Jws<Claims> jws = Jwts.parser()
    .keyLocator(unsafeLocator)
    .build()
    .parseSignedClaims(token);

Why this is vulnerable: the branch on the header's alg is exactly the attacker-controlled input that should never influence which key type gets returned. An attacker changes the token header to HS256 and signs it with an HMAC computed over the server's own RSA public key bytes; the locator, trusting the header, hands back exactly that key material for HMAC verification.

This is the shape of the attack that is still live on a current library, and worth separating from the version that is not. Measured on jjwt 0.13.0, Jwts.parser().verifyWith(rsaPublicKey) refuses the same forged token with UnsupportedJwtException - jjwt will not use an RSA public key to verify an HMAC signature, so the library closes the door on its own. What it cannot close is a locator that fetches an actual HMAC key when asked to, because that conversion happens in application code. The forged token above is accepted and getSubject() returns the attacker's subject, while the legitimate RS256 token continues to work, which is what keeps this code in production.

The pre-0.12 spelling of this is SigningKeyResolverAdapter with setSigningKeyResolver() and parseClaimsJws(). Both still compile on 0.13.0 but are deprecated, and Jwts.parserBuilder() from the same era was removed outright - so a codebase carrying this pattern is usually also carrying the old API, and the migration and the fix are the same edit.

Trusting XMLSignature.validate() Without Checking the Signed Reference

import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import org.w3c.dom.Element;

// VULNERABLE - proves a signature is cryptographically valid, not that it
// covers the element the application is about to trust
DOMValidateContext valContext = new DOMValidateContext(publicKey, signatureNode);
XMLSignatureFactory factory = XMLSignatureFactory.getInstance("DOM");
XMLSignature signature = factory.unmarshalXMLSignature(valContext);

if (signature.validate(valContext)) {
    // reads a DIFFERENT element by tag name, not the one referenced by the
    // signature - an attacker can inject a second, unsigned element with
    // the same tag name elsewhere in the document (XML Signature Wrapping)
    Element amountElement = (Element) doc.getElementsByTagName("Amount").item(0);
    processPayment(amountElement.getTextContent());
}

Why this is vulnerable: XMLSignature.validate() proves the signature is cryptographically consistent with whatever the <Reference URI> points to and whatever key was supplied to the validation context. It does not prove that the element the application later reads by tag name is the one that was referenced. An attacker who can insert a second element into the document can smuggle unsigned content past business logic while leaving the original, genuinely signed element untouched.

Secure Patterns

Always Check the Boolean Result and Fail Closed

import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;

// SECURE - the boolean result is checked; any exception is treated as failure
public void processSignedRequest(byte[] data, byte[] signatureBytes, PublicKey publicKey)
        throws SecurityException {
    boolean valid;
    try {
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initVerify(publicKey);
        sig.update(data);
        valid = sig.verify(signatureBytes);
    } catch (Exception e) {
        throw new SecurityException("Signature verification error", e);
    }
    if (!valid) {
        throw new SecurityException("Invalid signature");
    }
    handleRequest(data);
}

Why this works: The verification outcome now has exactly one path to handleRequest(): a caught exception or a false return both throw before the request is processed, so a failure can no longer be logged and then treated as success.

Pin the Verification Algorithm and Key Type

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.proc.SingleKeyJWSKeySelector;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;

// SECURE - algorithm and key are pinned server-side, never read from the token header
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
JWSKeySelector<SecurityContext> keySelector =
    new SingleKeyJWSKeySelector<>(JWSAlgorithm.RS256, rsaPublicKey);
jwtProcessor.setJWSKeySelector(keySelector);

JWTClaimsSet claims = jwtProcessor.process(token, null); // throws BadJOSEException on any mismatch

Why this works: SingleKeyJWSKeySelector binds one specific algorithm to one specific key at configuration time, before any token is seen. ConfigurableJWTProcessor.process() rejects a token whose header names a different algorithm before verification is attempted, so there is no code path where the header influences which key or algorithm family gets used. Measured on nimbus-jose-jwt 10.6: the legitimate RS256 token is accepted, an RS512 token signed by the same private key is refused with BadJOSEException: Signed JWT rejected: Another algorithm expected, or no matching key(s) found, and an alg: none token with Unsecured (plain) JWTs are rejected.

jjwt: verifyWith() Fixes the Key, Not the Algorithm

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;

// SECURE - the key is fixed before the token is read, and the algorithm the
// token actually used is asserted afterwards
Jws<Claims> jws = Jwts.parser()
    .verifyWith(rsaPublicKey)   // a typed PublicKey, never a byte[] that could serve as an HMAC secret
    .build()
    .parseSignedClaims(token);

if (!"RS256".equals(jws.getHeader().getAlgorithm())) {
    throw new SecurityException("Unexpected token algorithm: " + jws.getHeader().getAlgorithm());
}

String subject = jws.getPayload().getSubject();

Why this works: verifyWith() takes a typed PublicKey or SecretKey, so the parser knows the key's type before it sees the token and will not use it for an algorithm it does not suit. Measured on jjwt 0.12.6 and 0.13.0, that alone refuses an alg: none token (UnsupportedJwtException), a token signed with a different key (SignatureException), and the RS256-as-HS256 forgery (UnsupportedJwtException, naming the key type).

What it does not do is pin the algorithm, which is why the assertion is not decoration. jjwt takes the algorithm from the token's own alg header and only requires that the key suits it, so on the same key an RS512 token verifies against a handler that only ever issues RS256, and on a shared secret an HS512 token verifies against an HS256 handler - measured on both releases, with getSubject() returning the attacker's subject in each case. The symmetric half needs a secret of at least 64 bytes to demonstrate: with a shorter one, jjwt refuses the HS512 token with WeakKeyException (a 304-bit key "is not secure enough for the HS512 algorithm") before the gap can matter, which is a key-size rule and not an algorithm pin. This is a narrower gap than key confusion, since minting the token still needs the real key, but it means "the signature verified" is not the same statement as "this token was issued the way we issue tokens". Keeping the Jws<Claims> and checking getHeader().getAlgorithm() closes it in three lines. Where you want the pin enforced by the library instead, use the Nimbus processor above - SingleKeyJWSKeySelector refuses the RS512 token outright.

Verify XML Signatures Against a Trusted Key and the Exact Referenced Element

import javax.xml.crypto.dsig.Reference;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

// SECURE - verify with a key the application supplies, then confirm the
// reference covers the exact element that will be trusted
DOMValidateContext valContext = new DOMValidateContext(trustedPublicKey, signatureNode);
XMLSignatureFactory factory = XMLSignatureFactory.getInstance("DOM");
XMLSignature signature = factory.unmarshalXMLSignature(valContext);

boolean cryptoValid = signature.validate(valContext);

NodeList amountNodes = doc.getElementsByTagName("Amount");
if (!cryptoValid || amountNodes.getLength() != 1) {
    throw new SecurityException("Signature invalid or ambiguous element count");
}

Reference reference = (Reference) signature.getSignedInfo().getReferences().get(0);
String referencedId = reference.getURI().replaceFirst("^#", "");
Element amountElement = (Element) amountNodes.item(0);

if (!referencedId.equals(amountElement.getAttribute("Id"))) {
    throw new SecurityException("Signed reference does not cover the trusted element");
}

processPayment(amountElement.getTextContent());

Why this works: trustedPublicKey is supplied by the application rather than extracted from a <KeyInfo> block in the document, so an attacker cannot substitute their own key by editing the signature. Rejecting a document with more than one element matching the expected tag closes the simplest wrapping variant, and explicitly matching the <Reference URI> against the element's Id attribute confirms the cryptographically verified subtree is the same subtree the application is about to act on.

Constant-Time Comparison for Webhook HMAC Signatures

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;

// SECURE - webhook HMAC-SHA256 verification with constant-time comparison
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret, "HmacSHA256"));
byte[] expected = mac.doFinal(requestBody);

// The header is the attacker's to choose, and HexFormat.parseHex() throws
// IllegalArgumentException on an odd length or a non-hex character. Catch
// it here so a malformed signature is a rejection rather than a 500.
byte[] provided;
try {
    provided = HexFormat.of().parseHex(signatureHeader);
} catch (IllegalArgumentException e) {
    throw new SecurityException("Invalid webhook signature");
}

if (!MessageDigest.isEqual(expected, provided)) {
    throw new SecurityException("Invalid webhook signature");
}

Why this works: MessageDigest.isEqual(byte[], byte[]) (JDK 6u17+) is specified to run in time that depends only on the length of the arrays, not on where the first differing byte occurs, which removes the timing side channel. String.equals() and Arrays.equals() both short-circuit on the first mismatch and are not designed for security-sensitive comparisons.

The decode guard is not tidying. Measured on JDK 26, HexFormat.of().parseHex() throws IllegalArgumentException: string length not even for a 63-character header and NumberFormatException: not a hexadecimal digit for a 64-character one containing z or a byte above 0x7F - all three trivially sendable, all three reaching the decoder only through the attacker's input, so every legitimate-traffic test passes while the endpoint answers a server error to exactly the request it was hardened against. MessageDigest.isEqual() handles a length mismatch by returning false; the decode in front of it does not.

Framework-Specific Guidance

Spring Security OAuth2 Resource Server

import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;

// SECURE - NimbusJwtDecoder built from a JWKS URI validates signatures using
// only the key set fetched from that trusted endpoint, matched by kid and alg
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withJwkSetUri("https://issuer.example.com/.well-known/jwks.json")
        .jwsAlgorithm(org.springframework.security.oauth2.jose.jws.SignatureAlgorithm.RS256)
        .build();
}

Why this works: .jwsAlgorithm(RS256) restricts the decoder to a single algorithm regardless of what the JWKS document or token header claims, and the decoder resolves keys only from the configured JWKS URI - there is no application code path where a header value chooses between key types.

Considerations

This is one of the few findings that is almost never a false positive. For most weaknesses the first question is whether the value is security-relevant; here, if a signature is being checked at all, something is trusting the result. The narrow exception is data that never crossed a trust boundary - a token your own process minted, held in memory, and verified moments later. If the token arrived over the network, the check matters.

Decide where verification happens, and whether once is enough. A gateway that verifies tokens before forwarding lets backend services skip the work, which is efficient and fine until one service becomes reachable another way - an internal caller, a service mesh retry, a debugging port. Verifying again in the service costs little and does not depend on network topology staying as drawn. If you do rely on the gateway, make it impossible to bypass rather than merely inconvenient.

Symmetric algorithms give every verifier the power to mint. HS256 uses one shared secret, so any service holding it to check tokens can also issue them. With three services and one secret you have three places a forged administrator token can come from. RS256 and EdDSA split that: the issuer holds the private key, verifiers hold only the public one. If more than one service verifies, that separation is worth the extra key management.

Decide the skew allowance rather than inheriting it. Nimbus DefaultJWTClaimsVerifier and jjwt both let you set a clock-skew tolerance, and the two libraries default differently. State the value you want in configuration so it is visible in review, rather than depending on which library a service happens to use.

Key rotation needs a cache policy decided in advance. Resolving keys by kid from a JWKS endpoint means an outbound fetch on the verification path. Cache too briefly and every request becomes a network call, so an issuer outage takes your authentication down with it; cache too long and a rotated-away key stays trusted. Cache by kid with a refresh on unknown values, plus a floor on how often that refresh can fire, so an attacker cannot drive fetches by sending tokens with random kid values.

Expiry is not revocation. Signature verification proves a token was issued and unmodified; it says nothing about whether the account was disabled a minute ago. Short lifetimes narrow that window and cost a refresh round trip; a revocation list closes it and costs a lookup on every request. Which you need depends on how quickly access must actually stop - "immediately" and "within fifteen minutes" are different systems.

Testing

  • Normal: a legitimately issued RS256 token, a correctly HMAC-signed webhook payload, and a validly signed XML document are all accepted.
  • Boundary: a token signed by an unknown kid, and an XML document with zero matching elements for the referenced ID, are both rejected without throwing an unhandled exception.
  • Malicious - cross-algorithm: re-sign a valid RS256 token as RS512 with the issuer's real private key (or, on a symmetric deployment, mint an HS512 token with the real secret). Nimbus must throw BadJOSEException. jjwt will parse it without complaint, so the assertion here is on your own code: getHeader().getAlgorithm() must be checked and the request refused. This is the one test in this list that fails on an unfixed jjwt handler, which makes it the one worth writing first.
  • Malicious - algorithm confusion: craft a token re-signed as HS256 using the known RSA public key as the HMAC secret; verification must fail (SignatureException for Signature, BadJOSEException for Nimbus, UnsupportedJwtException for jjwt). On current jjwt and Nimbus this passes before any fix, because the libraries refuse the key type - it is a regression test, not evidence that your configuration is right. If your code has a key locator with a symmetric branch, run the test against that branch instead.
  • Malicious - alg=none: submit a JWT with header {"alg":"none"}; verification must reject it, not treat the empty signature as valid.
  • Malicious - XML wrapping: insert a second, unsigned element with the same tag/ID pattern as the signed element; verification must either fail or the application must act only on the originally referenced element.
  • Malicious - tampered webhook payload: flip one byte in the request body while keeping the original signature header; MessageDigest.isEqual() must return false.

Common Pitfalls

  • Checking Signature.verify()'s return value in one call site but not another: codebases with more than one place calling Signature.getInstance(...).verify(...) (a legacy path alongside a newer JWT-based one, for example) often fix the finding where the scanner pointed and miss a sibling call. Grep for .verify( across the codebase, not just the flagged line.
  • Configuring JWSKeySelector/verifyWith() correctly but leaving an older SigningKeyResolver class unused-but-present and still wired into a different endpoint: a partial migration can leave two verification paths active, one safe and one not. Confirm every JWT-consuming endpoint uses the pinned selector, not just the one that was patched first. setSigningKeyResolver() and parseClaimsJws() are deprecated on jjwt 0.12+ but still compile, so the old path does not announce itself with a build failure - only Jwts.parserBuilder() was removed outright. Build with -Xlint:deprecation and read the warnings.
  • Reading verifyWith() as the whole fix because its name says "verify": it fixes the key and leaves the algorithm to the token. That is enough against key confusion and not against a token minted under a different algorithm with the same key, so the header assertion has to be written by hand. This is a real difference from Nimbus and from firebase/php-jwt, both of which pin the exact algorithm, and it is the kind of gap that gets closed on one page of a codebase and left open on the next.
  • Trusting jku, x5u, jwk or x5c to locate the key: a Locator/JWSKeySelector that reads any of these is taking key provenance from an unauthenticated header, so the sender supplies the key their own token verifies against. kid is different only because it indexes a table you populated. For Nimbus, RemoteJWKSet and JWKSourceBuilder should be constructed with a fixed URL; passing one derived from the token turns the authentication path into an outbound request to an attacker-chosen host (CWE-918).
  • Validating the XML signature but reading the trusted element by getElementsByTagName() before checking the count: if the document can contain more than one element with the same tag name, taking item(0) without first checking getLength() == 1 (or matching against the reference URI) leaves an XSW gap even after the cryptographic validation itself is correct.

Dependencies and Installation

  • com.nimbusds:nimbus-jose-jwt or io.jsonwebtoken:jjwt-api/jjwt-impl/jjwt-jackson - keep at a current maintained version; both have had past advisories related to algorithm handling.
  • javax.xml.crypto (JSR 105) ships with the JDK; no separate dependency, but keep the JDK itself patched for XML processing fixes.
  • javax.crypto/java.security (JCE/JCA) are part of the JDK standard library.

Migration Considerations

Pinning the algorithm - Nimbus's JWSAlgorithm, or the explicit header assertion alongside jjwt's verifyWith - will reject any previously accepted token signed with an algorithm being removed. The case that catches people is not a second algorithm anyone chose but one nobody noticed: a second issuer configured with RS512, or a signing library whose default changed on an upgrade. Read the alg off a sample of live tokens rather than off the issuer's configuration, then narrow to what you actually see. Expect active sessions signed under a now-rejected algorithm to require re-authentication.

Moving off SigningKeyResolver is the other half of this on jjwt, and it is a bigger edit than the deprecation warning suggests. Locator<Key> receives a Header rather than a JwsHeader and Claims pair, so a resolver that branched on claim values has nowhere to read them; restructure it to look the key up by kid alone and move any claim-dependent logic after parseSignedClaims(), where the claims are verified.

Additional Resources