Dear AI: Your HashedPassword Implementation Has Trust Issues

I’ve been playing around with AI code generation for a while. I’ve tried to build out a secure by default framework centered around Domain Driven Design. Value objects are one of the core architectural patterns of Domain Driven Design and drive building secure software. This implementation of a HashPassword value object should have gone a lot smoother than it did.

Why Value Objects Matter (The Good News First)

Before we dissect what went wrong, here’s why value objects facilitate building secure software.

Strong Typing That Actually Types
Instead of passing around generic String parameters and hoping developers don’t accidentally swap a username for a password hash, value objects create distinct types. The IDE and the compiler catch improper variable handling errors before they can happen in runtime.

Readability That Reads
When developers see processLogin(UserId userId, HashedPassword password) in a method signature, they immediately understand what’s expected. No more squinting at parameter names, wondering if userAuth contains the hash or the salt or someone’s coffee order. Every developer has seen the lengthly parameter list of String, String, String, int, int int.

Validation Where It Belongs
Validation is one of the most important lines of defense. Value objects validate data at construction time. This places validation in a common location. In this case, once you have a valid HashedPassword object, you know it meets your criteria.

The Implementation That Seemed So Right

Here’s a HashedPassword value object that looks reasonable at first glance:

/**
 * Represents a securely hashed password.
 * This object encapsulates the full hash string, which should include
 * the algorithm, salt, cost factors, and the hash itself.
 * It does not store the plaintext password.
 */
public record HashedPassword(String fullHash) implements ValueObject {

    /**
     * Canonical constructor.
     *
     * @param fullHash The complete password hash string (e.g., output from Argon2, bcrypt, scrypt).
     *                 Must not be null or blank.
     * @throws IllegalArgumentException if fullHash is null or blank.
     */
    public HashedPassword {
        Objects.requireNonNull(fullHash, "fullHash cannot be null");
        if (fullHash.isBlank()) {
            throw new IllegalArgumentException("fullHash cannot be blank");
        }
    }

    /**
     * Provides the full hash string.
     *
     * @return The password hash.
     */
    public String value() {
        return fullHash;
    }

    // equals, hashCode, and toString are automatically generated by the record.
    // toString will print the hash, which is generally acceptable as it's not the plaintext.
}

Clean, modern Java. Uses records. Has validation. What could go wrong?

Narrator: Many things could go wrong.

Flaw #1: The Data Leakage

Java records automatically generate a toString() method that cheerfully dumps all your fields into a string. It simply writes whatever fields are part of the record without concern for what value they hold.

The Problem:
This default functionality means the “fullHash” gets logged, printed, or can appear in stack traces. The “secure” hash gets broadcast to anyone with log access. Exception handlers, debugging sessions, and that helpful logging framework you installed all become inadvertent password leak vectors.

The Misleading Comment:
Look at that last comment: “toString will print the hash, which is generally acceptable as it’s not the plaintext.” The AI explicitly acknowledges the hash exposure and waves it off as “generally acceptable.” This misleading comment can lead to further issues.

The Reality Check:
“But it’s just a hash!” This comment embodies that dangerous thinking. Hashes are users data and access to them still need to follow the “least privilege” rule. While they may hide the real password, attackers can still leverage the hash in many ways. They enable timing attacks, can be reverse-engineered with sufficient computing power or rainbow tables, and provide attackers with valuable information about your password policies and user behavior. That comment just gave every future maintainer permission to ignore this security concern.

The Fix:
Override toString() to return something safe, or use a custom record implementation that doesn’t expose sensitive data. Your logs will thank you, and your security team won’t plot your professional demise.

@Override
public String toString() {
    return "HashedPassword[REDACTED]";
}

Flaw #2: The Memory Persistence Problem

Java’s String class is not a standard object. It is immutable and stick around in memory far longer than when it is used last. When dealing with sensitive data like password hashes, this creates a window of vulnerability that garbage collection timing can’t fix.

The Java-Specific Issue:
Strings in Java live in the string pool and heap memory, beyond your direct control. Even after your HashedPassword object goes out of scope, the actual hash string may persist in memory until some indeterminate garbage collection cycle decides to clean house.

This isn’t just a Java problem. C# has similar issues with regular strings (though SecureString provides an alternative). Python strings are immutable objects that can persist in memory. Even languages with more aggressive garbage collection can leave sensitive data hanging around longer than intended.

The Better Approach:
This creates a fundamental tension: value objects should be immutable, but secure memory handling requires explicit cleanup. You have a few architectural choices:

  1. Accept the trade-off and use defensive copying with byte[], knowing some memory exposure remains
  2. Move security concerns up a layer to a service that manages secure byte arrays and only exposes safe operations
  3. Use platform-specific secure containers (like .NET’s SecureString) that handle cleanup internally
  4. Rethink whether a simple value object is the right pattern for this security-critical data
// Option 1: Defensive copying approach
public record HashedPassword(byte[] hashBytes) implements ValueObject {
    public HashedPassword(byte[] hashBytes) {
       hashBytes = hashBytes.clone(); // Defensive copy
    }

    public byte[] getBytes() {
        return hashBytes.clone(); // Don't expose internal array
    }
}

Flaw #3: The Comment Catastrophe (AI’s Architectural Confusion)

Comments need to fulfill a purpose. They are intended to clarify decision making. They are intended to explain some complex code. The comments in the

The Obvious Problem:
Code duplication like “Must not be null or blank” directly above a null check aren’t documentation. They often go stale when code changes causing confusion when the code.

The Deeper Issue:
I have seen these comments from AI all the time. When debugging issues that AI code creates, changes have comments like “— CORRECTED CODE —“. As if these are supposed to be helpful? AI can write syntactically correct code. But it often lacks context about your specific architectural decisions. The comments reveal this uncertainty, focusing on implementation mechanics rather than business rationale.

The Team Impact:
Code is read far more than it’s written. When teammates (or future you) encounter this code, those meandering comments create cognitive overhead. Instead of understanding why you chose this validation strategy, readers get bogged down in explanations of what requireNonNull does.

The AI Debugging Problem:
When AI tools analyze your codebase for debugging or refactoring suggestions, these verbose comments can mislead the analysis. The AI focuses on the commented explanations rather than the actual architectural intent, potentially suggesting changes that break your design patterns.

The Clean Alternative:
Let the code speak for itself. Use meaningful variable names, clear method signatures, and comments that explain why, not what. Here’s a version that actually addresses all three flaws:

/**
 * A value object representing a securely hashed password.
 * Expects a hash from a secure algorithm like bcrypt, PBKDF2, or Argon2.
 * Hash is not exposed to prevent leakage; use verify() for comparison.
 */
public record HashedPassword(byte[] hashBytes) implements ValueObject {
    public HashedPassword {
        validateHashBytes(hashBytes);
        this.hashBytes = hashBytes.clone(); // Defensive copy preserves immutability
    }

    private void validateHashBytes(byte[] hash) {
        Objects.requireNonNull(hash, "Hash bytes cannot be null");
        if (hash.length == 0) throw new IllegalArgumentException("Hash bytes cannot be empty");
        if (hash.length < 32) throw new IllegalArgumentException("Hash too short; minimum 32 bytes required");
    }

    /**
     * Verifies if the provided hash matches this hashed password in a secure, constant-time manner.
     * @param otherHash the hash to compare against
     * @return true if the hashes match, false otherwise
     * @throws IllegalArgumentException if otherHash is null
     */
    public boolean verify(byte[] otherHash) {
        Objects.requireNonNull(otherHash, "Verification hash cannot be null");
        return java.security.MessageDigest.isEqual(this.hashBytes, otherHash);
    }

    @Override
    public String toString() {
        return "HashedPassword[REDACTED]";
    }
}

The Path Forward

Value objects remain powerful tools for creating robust, type-safe domain models. But like any powerful tool, they require thoughtful implementation. Avoid the auto-generated toString() trap, consider memory security for sensitive data, and resist the urge to over-comment obvious code.

Your future self, your security team, and your fellow developers will appreciate the extra attention to detail. And who knows? You might even prevent the next security incident that starts with “Well, it was just a hash in the logs…”

Remember: Good architecture isn’t just about making code work—it’s about making code work safely, maintainably, and without unpleasant surprises six months later.

Posted in Uncategorized.