Skip to content

CWE-261: Weak Encoding for Password

Overview

Weak encoding for a password means applying a trivially reversible transformation - Base64, hex, URL encoding, ROT13, or an XOR against a key that ships with the code - and treating the result as if it were protected. Undoing any of them takes the same technique that applied them, usually in a single command: the encodings need no key at all, and the XOR needs one an attacker reads out of the source or config alongside the data it protects. An encoded password is therefore no better protected than one stored in plain text.

Relationship to Other CWEs

CWE-261 is ChildOf CWE-522 (Insufficiently Protected Credentials), the general weakness of credentials lacking adequate protection. CWE-261 is the specific case where the "protection" applied is a reversible encoding rather than a real cryptographic control.

The entries below are reported against the same column, and the test that separates them is what it takes to get the password back:

  • CWE-261 (this page) - a reversible encoding stands in for a password hash, so the stored value decodes straight back to the password with the technique that produced it.
  • CWE-916, CWE-759 and CWE-760 - the weak password hashing family assumes a genuine one-way hash is in use, with the algorithm, cost or salt too weak. If the stored value can be decoded back to the original password with a known, fixed procedure - not brute-forced or cracked - that is CWE-261 rather than the hashing family.
  • CWE-256 (Plaintext Storage of a Password) - covers passwords stored with no transformation at all. CWE-261 is the narrower case where a trivial, reversible transform was applied and mistaken for protection: the destination is the same real password hash, but the finding describes a different starting state and a different migration.

OWASP Classification

A04:2025 - Cryptographic Failures

Risk

Critical: an encoded password can be reversed with the same encoding function run backward - base64 -d, a URL-decode call, a single XOR pass - so a database or config leak exposes every plaintext password immediately, with no cracking effort required.

Remediation Steps

Core Principle: never encode a password as a substitute for hashing it; use a modern password hashing algorithm with a unique per-password salt.

Trace the Data Path

  • Source: the password value at registration, password-change, or a data-migration step
  • Sink: the storage column or config value the encoded password is written to
  • Missing control: a reversible encoding function stands in for a one-way password hash with no cryptographic cost factor

Hash Passwords, Don't Encode Them (Primary Defense)

  • Use Argon2id for new systems: it is OWASP's first choice, and memory-hard as well as slow, so an attacker cannot buy speed with parallel hardware. Use scrypt where Argon2id is unavailable; it is memory-hard for the same reason
  • Use bcrypt only where neither is available: OWASP scopes it to legacy systems because it is CPU-hard but not memory-hard, and it ignores input past 72 bytes - silently in most implementations, though some raise instead. Work factor 10 is OWASP's floor, 12 a common baseline - tune it on production-class hardware
  • Use PBKDF2-HMAC-SHA256 (600,000 iterations) only where FIPS-140 validation requires it. The iteration count is per-PRF and not interchangeable: SHA-512 takes 220,000, and SHA-1 1,400,000 for legacy use only
  • CWE-916 carries the full parameter sets for each of these and the procedure for tuning them
  • Generate a unique, random salt per password - modern hashing libraries handle this automatically as part of the stored hash value
  • Verify passwords by re-hashing the submitted value and comparing it to the stored hash - never by decoding the stored value

Understand Encoding vs. Encryption vs. Hashing

Method Reversible? Correct use case
Encoding (Base64, hex, URL, ROT13) Yes, trivially, no key needed Data representation/transport only
Encryption (AES-GCM, etc.) Yes, with the key Data you must recover later - never passwords
Hashing (Argon2id, scrypt, bcrypt, PBKDF2) No Passwords - you only ever need to verify, not recover

Encrypting a password instead of hashing it is also the wrong fix: the decryption key has to exist somewhere the application can reach it, so anyone who compromises the app or its configuration can decrypt every password at once.

Test the Remediation

  • Attempt to decode the stored value with the previously-used encoding (Base64, hex, URL-decode) - it should fail or produce garbage, not the original password
  • Confirm two users with the same password get different stored hashes, proving a per-password salt is in use
  • Confirm the stored value carries the hashing algorithm's own identifying prefix rather than looking like encoded text. Match the prefix to the stack that produced it, not to a literal copied from elsewhere: bcrypt is $2b$ from Python's bcrypt and from bcryptjs, $2y$ from PHP's password_hash, and $2a$ from Spring Security and BCrypt.Net, while Argon2id is $argon2id$ everywhere
  • Re-scan with a security tool to confirm the finding is resolved

Common Vulnerable Patterns

// VULNERABLE - encoding mistaken for protection
stored_password = base64_encode(password)
...
// "verification" just decodes and compares plaintext
is_valid = (base64_decode(stored_password) == submitted_password)

Why this is vulnerable: an encoding has no key, so there is no secret involved. Where the transform does take one, as an XOR against a constant does, the key sits in the repository or config file next to the code that applies it, which comes to the same thing. Base64 exists to move bytes through channels that expect text, and reversing it requires knowing only that it is Base64 - which its own alphabet and padding announce. The stored value is the password, written differently, and the verification step in the example admits as much: it recovers the plaintext in order to compare it.

That last line is the reliable tell, and it is worth more than recognising any particular encoding. If checking a password requires reversing what is stored, then what is stored is recoverable by anyone who reads it, whatever the transformation is called. A correct scheme never recovers the original - it applies the same one-way function to the submitted value and compares the results - so a verification routine containing a decode, a decrypt, or an unwrap is reporting the defect regardless of how strong the algorithm around it looks.

Secure Patterns

// SECURE - one-way hash with a per-password salt, generated by the library
stored_hash = password_hash(password, algorithm="argon2id")
...
// verification re-hashes the input and compares - never decodes
is_valid = password_verify(submitted_password, stored_hash)

Why this works: the hash cannot be reversed to recover the original password - it can only be checked by hashing a candidate value and comparing. The salt embedded in the stored hash ensures identical passwords produce different stored values, defeating precomputed lookup tables.

Common Pitfalls

  • Stacking multiple encodings: Base64-encoding twice, or combining Base64 with XOR, on the theory that layering weak transforms adds strength - composing reversible functions is still reversible; the combined transform can be undone the same way each individual step was.
  • Encrypting instead of hashing: using AES or another symmetric cipher to protect the password so it can be "checked" by decrypting and comparing - the decryption key must be reachable by the application to verify logins, so a compromise of the app or its config exposes every password, not just one.
  • Preserving reversibility during migration: replacing the encoding with a "stronger" but still-reversible scheme (a different cipher, a longer XOR key) instead of moving to a real hash - login only ever needs to compare, not recover, the password, so there is no reason the migration target needs to be reversible at all.
  • Leaving a dual verification path indefinitely: hashing new passwords correctly while keeping a decode-and-compare fallback for old rows "until they log in again," with no cutover plan - accounts that never log back in stay in the encoded, effectively unprotected state permanently.

Migration Considerations

What Breaks

Nothing breaks for users: the passwords themselves do not change, so everyone who could log in before the migration can log in after it. What breaks is any code that reads the stored value back - an admin screen that displays a password, a support tool that mails it out, a batch job that decodes the column to authenticate somewhere else. Find those callers first, because once the column holds hashes there is nothing to decode and they will fail at the point they are used rather than at deploy time.

Migration Approach

An encoded estate migrates in a single pass, and this is the one respect in which CWE-261 is easier than the hashing family. A legacy hash estate (CWE-759, CWE-916) has lost the plaintext, so the best it can do in bulk is layer the new function over the stored digests, and each record only reaches the clean form when its owner next logs in. Here the plaintext is still recoverable - that is the defect - so the same property that makes the storage worthless makes the fix immediate.

  • Batch convert (recommended): decode each stored value, hash the recovered plaintext with the algorithm chosen above, write the hash, and drop the encoded column once the whole table is converted and logins are verified. No user is disrupted and no encoded row is left waiting for a login, which matters because a database dump does not wait for users to log in either. Run it as a one-off job that holds the decoded value only in memory - do not stage it into a temporary column, a CSV, or a log line.
  • Force a reset as well where the encoded values may already have leaked: they were effectively plaintext, so hashing them now does not undo an exposure that has already happened. If the column reached a backup, a log, a support ticket, or a breach, treat those passwords as compromised for this site and everywhere they were reused, and reset them - the same call CWE-256 describes for a plaintext column.
  • Gradual migration on next login (fallback): where a batch job is genuinely not possible, decode the old value on a successful login, verify it against the submitted password, then hash the password and clear the encoded value. Set a cutover date, force-reset whatever has not migrated by then, and remember that every un-migrated row stays readable for the whole window.

Rollback Procedures

Deploy the login path that verifies against a hash first and confirm it is live on every instance, then run the conversion: a build that only understands the encoded form authenticates nobody once the batch has run. Take a database backup immediately beforehand and treat it as the only route back, because a converted row cannot be turned back into an encoded one. That backup is a copy of every password in a recoverable form, so it is subject to the bullet above rather than exempt from it: encrypt it, restrict it to the people running the migration, and destroy it once logins are verified - a rollback tape left in a backup rotation is the finding, preserved. Do not keep the encoded column as a safety net after the batch completes and logins are verified - it is a second copy of every password, which is the original finding under a different column name.

Additional Resources