Skip to content

CWE-597: Use of Wrong Operator in String Comparison - Java

Overview

In Java, == on two String references asks whether they are the same object. It never compares content. A literal in source is interned, so two literals with the same text are the same object and the comparison happens to be true; a string that arrived from a request parameter, a JDBC result set, a JSON body or a JWT claim is a fresh instance, so the comparison is false whatever it contains. Measured on JDK 26, new StringBuilder("ADM").append("IN").toString() == "ADMIN" is false while .equals("ADMIN") is true, and javac -Xlint:all reports nothing at all.

Which way the bug fails decides whether it is an outage or a bypass, so settle that before anything else:

  • A positive check - if (role == "ADMIN") allow - denies everybody, including whoever is testing it. It is a real weakness and it usually gets caught in development, because nothing works.
  • A negative check - a denylist, a reserved-name test, a != guard whose false branch is the permissive one - never fires, so the restriction silently does not exist and every ordinary test still passes. This is the shape that reaches production.

The comparison is also intermittent in a way that makes it hard to reason about from a single test: "ADM" + "IN" is folded to a literal at compile time and is interned, an enum's name() and toString() return the interned constant from the class file, and intern() on anything returns the canonical instance - all measured true against the literal on JDK 26. So the same expression can pass in a unit test built from constants and fail against live data.

Primary Defence: Use .equals() for string content, with the constant on the left ("ADMIN".equals(role)) so a null value returns false instead of throwing; use Objects.equals() when either side may be null; prefer an enum over a string wherever the set of values is fixed; and use MessageDigest.isEqual() for anything secret, where content equality is necessary but not sufficient.

Common Vulnerable Patterns

Reference Equality in Authentication

// VULNERABLE - reference equality (==)
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password) {
    String storedPassword = userService.getPassword(username);

    if (password == storedPassword) {  // WRONG - compares object identity
        return "redirect:/dashboard";
    }
    return "login?error";
}

Why this is vulnerable: == asks whether the two references point at the same object. @RequestParam produces a String decoded from the request line and getPassword produces one built by the JDBC driver, so they are always different instances and the branch is never taken - the endpoint refuses every correct password. Read this as an availability and correctness defect rather than an authentication bypass: it fails closed, and the way it usually causes a breach is indirect, when the login is "fixed" under time pressure by something that skips the check. Two other things on this line deserve their own findings while you are here: getPassword returning a comparable password at all means the store holds them recoverably - as plaintext (CWE-256) or under a reversible encoding (CWE-261), and the correct replacement is a password encoder's matches(), not .equals() on a hash.

Reference Equality in a Denylist

// VULNERABLE - a reserved-name check that can never match
private static final List<String> RESERVED = List.of("admin", "root", "system");

@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegistrationRequest request) {
    for (String reserved : RESERVED) {
        if (request.getUsername() == reserved) {   // WRONG - never true
            return ResponseEntity.badRequest().body("That username is reserved.");
        }
    }

    userService.create(request.getUsername());     // "admin" is created
    return ResponseEntity.ok().build();
}

Why this is vulnerable: This is the direction that grants. The username is deserialized from the request body, so it is a fresh instance and matches no element by identity; the loop completes, the rejection branch is never reached, and the account is created. Measured on JDK 26: equals returns true for the same pair the loop rejects, so the check is not merely weak but inert. Nothing in the code path reports a problem, and every test that asserts an ordinary registration succeeds still passes - the only assertion that catches it is one that posts a reserved name and expects a 400. Worth knowing for contrast: switch on a String and Collection.contains both compare with equals, so RESERVED.contains(request.getUsername()) does what the loop was trying to do - with the caveat that a List.of/Set.of collection throws NullPointerException rather than returning false for a null argument, so the null still needs its own rejection. The bug appears only where somebody wrote the operator out by hand.

Reference Equality in Authorization

// VULNERABLE - role check with ==
@GetMapping("/admin")
public String adminPanel(Principal principal) {
    User user = userService.findByUsername(principal.getName());
    String role = user.getRole();  // From the database - a new String instance

    if (role == "ADMIN") {  // false for a database value, true for an interned one
        return "admin";
    }
    return "access-denied";
}

Why this is vulnerable: The result depends on where the value came from rather than on what it is, which makes the check unpredictable across code paths rather than uniformly broken. Measured on JDK 26: a role read from the database is a new instance and the comparison is false, while Role.ADMIN.name() and Role.ADMIN.toString() both return the interned constant and compare true against the literal. So the same method can grant when the caller went through an enum-backed path and deny when it went through the repository, and a test that constructs the User in-line with a literal passes while production fails. That divergence is the real hazard here: it does not present as "authorization is broken", it presents as an intermittent bug that somebody eventually works around.

Reference Equality in CSRF Token Validation

// VULNERABLE - token comparison with ==
@PostMapping("/transfer")
public ResponseEntity<?> transfer(@RequestParam String csrf, HttpSession session) {
    String sessionToken = (String) session.getAttribute("csrf");

    if (csrf == sessionToken) {  // WRONG - reference comparison
        processTransfer();
        return ResponseEntity.ok("Transfer complete");
    }
    return ResponseEntity.status(403).body("Invalid CSRF token");
}

Why this is vulnerable: request.getParameter() and session.getAttribute() return different instances even when the token text is identical, so every request is refused including the legitimate ones - CSRF protection that rejects everything is an outage, not a defence. The correction has a second half that .equals() does not supply: a CSRF token is a secret being compared against a submitted value, so the replacement belongs in MessageDigest.isEqual() rather than .equals(). See the secure pattern below, and CWE-208 for what a short-circuiting comparison does and does not leak.

Reference Equality in a JWT Claim

// VULNERABLE - issuer comparison with ==
public boolean validateToken(String token) {
    Claims claims = Jwts.parser()
        .verifyWith(secretKey)
        .build()
        .parseSignedClaims(token)
        .getPayload();

    String issuer = claims.getIssuer();  // parsed out of the token - a new String

    if (issuer == "https://myapp.com") {  // WRONG - always false
        return true;
    }
    return false;
}

Why this is vulnerable: jjwt builds the issuer from the token's decoded JSON payload, so it is a fresh String and never the same object as the literal. The method returns false for every token, including valid ones, so the endpoint stops accepting anything - and because the signature check above it did succeed, the failure looks like a token or clock problem rather than a comparison bug. There is a fix better than .equals() here: jjwt validates the issuer itself if you ask it to, with Jwts.parser().requireIssuer("https://myapp.com"), which throws IncorrectClaimException when the claim does not match and MissingClaimException when it is absent, so there is no hand-written comparison left to get wrong. (The pre-0.12 spelling of this parse - Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody() - no longer compiles: Jwts.parser() returns a JwtParserBuilder from 0.12 onwards and parseClaimsJws is not on it. Verified against jjwt 0.13.0.)

Secure Patterns

Value Equality, With the Constant on the Left

// SECURE - .equals() compares content; the constant receiver is null-safe
@PostMapping("/validate")
public ResponseEntity<?> validate(@RequestParam(required = false) String action) {
    if ("delete".equals(action)) {   // false when action is null - no NPE
        handleDelete();
        return ResponseEntity.ok("Deleted");
    }

    if ("update".equals(action)) {
        handleUpdate();
        return ResponseEntity.ok("Updated");
    }

    return ResponseEntity.badRequest().body("Unknown action");
}

// For a fixed set, a membership test says what is meant - but reject the null
// first, because neither of these tolerates one.
private static final Set<String> ALLOWED = Set.of("delete", "update");

if (action != null && ALLOWED.contains(action)) { ... }

Why this works: .equals() on String compares length and then content, so the result depends only on the text and not on how the instance was produced - user input, a database row and a literal all compare correctly against each other. Putting the literal on the left guarantees the receiver is non-null, so a missing parameter yields false rather than a NullPointerException that would surface as a 500.

Set.contains and switch on a String also compare with equals rather than ==, so both remove the operator choice from the code, and a membership test is the clearer spelling wherever the comparison really is one. Neither inherits the constant-first pattern's null tolerance, though: measured on JDK 26, Set.of("delete", "update").contains(null) throws NullPointerException - the immutable factory collections reject null outright - and so does a classic switch on a null String. new HashSet<>(...).contains(null) returns false instead, so the two Set implementations disagree. Guard the null explicitly, as above, rather than relying on which collection someone chose.

Objects.equals() When Either Side May Be Null

// SECURE - Objects.equals handles nulls on both sides
import java.util.Objects;

@Service
public class UserService {

    public boolean matchesUsername(User user, String targetUsername) {
        // Objects.equals(null, null)     => true
        // Objects.equals("admin", null)  => false
        // Objects.equals(null, "admin")  => false
        return Objects.equals(user.getUsername(), targetUsername);
    }
}

Why this works: Objects.equals(a, b) is implemented as (a == b) || (a != null && a.equals(b)), so it takes a null on either side as an ordinary value rather than throwing, and there is no receiver to choose. Use it when both operands are variables and either could be null, where the constant-first pattern has nothing to anchor on. The one case worth flagging is the first clause: two nulls compare equal. Where "no value stored" must not match "no value supplied" - a session token against a record, a submitted API key against an unset configuration property - reject the null explicitly before comparing, because Objects.equals(null, null) returns true and reads at the call site as a successful match.

Enums Instead of Strings for Roles and Permissions

// SECURE - the set of values lives in the type system
public enum Role {
    USER, ADMIN, MODERATOR
}

@Entity
public class User {
    @Enumerated(EnumType.STRING)   // stored as text in the database
    private Role role;

    public Role getRole() {
        return role;
    }
}

@GetMapping("/admin")
public String adminPanel(Principal principal) {
    User user = userService.findByUsername(principal.getName());

    if (user.getRole() == Role.ADMIN) {  // correct - enum constants are singletons
        return "admin";
    }
    return "access-denied";
}

Why this works: Each enum constant is a single instance for the lifetime of the class loader, so reference equality and value equality coincide and == is both correct and the idiomatic spelling. It also moves the valid set into the compiler: Role.ADMN is a compile error where "ADMN" is a comparison that silently never matches - which, in the denylist above, is the bypass. @Enumerated(EnumType.STRING) is worth being explicit about, because the JPA default is ORDINAL, which stores the declaration index; inserting a constant into the middle of the enum then silently reinterprets every existing row. == on an enum is also null-safe, where user.getRole().equals(Role.ADMIN) throws if the column is null.

Case-Insensitive Comparison

// SECURE - one call, no locale dependency
@GetMapping("/api")
public ResponseEntity<?> handleRequest(@RequestParam String method) {
    if ("POST".equalsIgnoreCase(method)) {  // matches "post", "Post", "POST"
        return handlePost();
    }

    if ("GET".equalsIgnoreCase(method)) {
        return handleGet();
    }

    return ResponseEntity.badRequest().body("Unknown method");
}

// AVOID - toLowerCase() with no Locale uses the default locale:
// if (method.toLowerCase().equals("post")) { ... }

Why this works: equalsIgnoreCase folds each character with Character.toUpperCase(Character.toLowerCase(c)), which is defined per code point and does not consult a Locale, so its result is the same on every machine. toLowerCase() with no argument uses Locale.getDefault(), which is set from the host environment: measured on JDK 26 with the default locale set to tr-TR, "ADMIN".toLowerCase() is "admın" with a dotless i and no longer equals "admin", while "ADMIN".equalsIgnoreCase("admin") stays true. That difference is a redeployment away on any application that folds case before comparing. Where a fold genuinely has to happen rather than a comparison - normalizing a key before storing it - name the locale: toLowerCase(Locale.ROOT).

Constant-Time Comparison for Secrets

// SECURE - constant-time comparison for tokens and signatures
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.security.crypto.password.PasswordEncoder;

@Service
public class AuthService {

    private final PasswordEncoder passwordEncoder;  // e.g. BCryptPasswordEncoder

    public AuthService(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    // Passwords: the encoder does the comparison, in constant time, against the
    // stored adaptive hash. Never hash a password with a bare digest and compare.
    public boolean validatePassword(String providedPassword, String storedHash) {
        return passwordEncoder.matches(providedPassword, storedHash);
    }

    // Tokens: hash both sides so the loop always walks 32 bytes and the token's
    // length is not measurable.
    public boolean validateCsrfToken(String providedToken, String sessionToken) {
        if (providedToken == null || sessionToken == null) {
            return false;
        }
        return MessageDigest.isEqual(sha256(providedToken), sha256(sessionToken));
    }

    private static byte[] sha256(String value) {
        try {
            return MessageDigest.getInstance("SHA-256")
                                .digest(value.getBytes(StandardCharsets.UTF_8));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 is required by the platform", e);
        }
    }
}

Why this works: .equals() fixes the comparison and leaves a second problem, which is that it exits at the first difference. MessageDigest.isEqual() runs its loop to completion whatever the arrays contain, so its duration depends on their length and never on how much of the content matched. Two details of it differ from the equivalent helpers in other languages: reading the JDK 26 source, it does not return early on a length mismatch - the difference is folded into the same accumulator (result |= lenA - lenB) - and the loop is bounded by the first argument, so that argument decides how long the call takes. Hashing both operands makes both points moot by fixing the length at 32 bytes. Passwords are a different problem and get a different answer: a stored password hash is produced by an adaptive function with a per-user salt, so there is nothing to compare it to directly, and PasswordEncoder.matches() both derives the candidate hash correctly and compares it in constant time. Comparing a bare MessageDigest of a password against a stored value would be CWE-916 whichever operator did the comparing.

Considerations

  • Establish which direction the check fails in before estimating the impact. A positive check that never matches is an outage; a denylist or != guard that never matches is a bypass with no symptom. The two get the same scanner finding and the same one-word fix, and they are not the same severity. Where the reported line is a positive check, the useful next question is whether the same file has a negative one written the same way.
  • The reported line is a sample, not the population. A codebase that compares strings with == in one security check usually does it in several, and a grep is cheap: grep -rn --include='*.java' -E '(==|!=)\s*"' src/ finds the literal-on-the-right form, and SpotBugs' ES_COMPARING_STRINGS_WITH_EQ, PMD's CompareObjectsWithEquals and SonarQube's S4973 find the ones a grep cannot. javac does not warn on any of them, so nothing surfaces at build time unless one of those is wired into the build.
  • .equals() is the right fix for a role and only half of it for a secret. Where the value being compared is a token, an API key, an HMAC or a signature, content equality is necessary and constant-time comparison is what the value actually needs; swapping == for .equals() closes the correctness bug and leaves the timing question open. This matters most for hand-written webhook and API-key checks, since Spring Security's adaptive password encoders already compare in constant time internally.
  • Interning makes a green test meaningless. A unit test that builds the expected value from a literal, a compile-time concatenation or Enum.name() exercises the interned path and passes against ==. Construct the input the way the request does - deserialize it, read it back from the repository, or wrap it in new String(...) - or the test cannot fail.

Testing

Static analysis proves the operator is gone. It cannot prove the check now does anything, and for the denylist shape that is the whole question:

  • Assert the accept with a value built at runtime. Call the corrected comparison with new String("ADMIN".getBytes(StandardCharsets.UTF_8)), a value round-tripped through the JSON mapper, or one read back from the repository - not a literal - and assert it matches. A comparison still done by identity passes every rejection test and fails only this one.
  • Post a reserved value to the denylist endpoint and assert the specific rejection. 400 with the reserved-name message, not merely "not a 5xx". A 200 here means the loop is still comparing by identity.
  • Drive the enum-backed and repository-backed paths of the same authorization check. Assert both produce the same decision for the same user. Divergence is the interning problem, and a test that exercises only one path cannot see it.
  • Send the request with the parameter omitted. Assert 400/403 with a body, not 500: a 500 means something called .equals() on a null receiver, and the error path is distinguishable from a genuine refusal.
  • Set -Duser.language=tr -Duser.country=TR on a test run and assert every case-insensitive comparison gives the same result as under the default locale. This catches a toLowerCase() that was left in place beside the corrected comparison.

Additional Resources