Encoding is not encryption is not hashing
Base64 is not a lock. SHA-256 is the wrong way to store a password. Three operations turn text into other text, and confusing them causes real breaches.
A developer base64-encodes a password, stores the result, and tells the auditor it is "encrypted." It is not encrypted. It is not even hidden from anyone who has ever seen a login token. This mistake and its two cousins cause real incidents, and all three grow from the same root: encoding, encryption, and hashing all turn text into other text, and past that surface they have nothing in common.
Three operations, three jobs
They look interchangeable and they are not. One reshapes data, one locks it, one fingerprints it. The differences that matter are whether the result can be reversed, and whether reversing it needs a key.
| Encoding | Encryption | Hashing | |
|---|---|---|---|
| Reversible? | Yes, by anyone | Yes, with the key | No, by design |
| Needs a key? | No | Yes | No |
| Output length | Grows with input | Grows with input | Fixed |
| The job | Move data through text-only channels | Keep data secret from anyone without the key | Fingerprint data, or verify it without storing it |
| Examples | Base64, URL-encoding | AES, RSA | SHA-256, bcrypt |
Encoding changes the clothes, not the meaning
Base64 is the one people trip over. It maps arbitrary bytes onto a 64-character alphabet that survives email bodies, URLs, JSON, and data: URIs without getting mangled. That is its whole purpose, and RFC 4648 is the whole specification.
$ printf '%s' "hunter2" | base64
aHVudGVyMg==
$ printf '%s' "aHVudGVyMg==" | base64 --decode
hunter2
There is no key. There is no secret. If you can read this article you can read that string, and so can Base64 Encoder, whose companion Base64 Decoder turns it right back into plaintext. The URL-safe variant swaps + and / for - and _ so the result fits in a link, which is why JWTs use it, but it is exactly as reversible. Base64 protects nothing. It never claimed to.
Encryption is reversible on purpose
Encryption is the only one of the three built to be undone. Feed plaintext and a key to a cipher like AES and you get ciphertext; feed that ciphertext and the same key back in and you get the plaintext. Without the key, good ciphertext is indistinguishable from noise. A properly used cipher even produces different ciphertext for the same input each time, because it mixes in a fresh random initialization vector. The point of encryption is confidentiality for the key-holder, and the ability to get the data back is a feature, not a flaw.
Hashing only goes one way
A hash is a one-way function. It is deterministic, it produces a fixed-length output no matter how large the input, and it is built to be infeasible to run backward.
$ printf '%s' "hunter2" | shasum -a 256
f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7 -
$ printf '%s' "hunter2" | shasum -a 256
f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7 -
$ printf '%s' "hunter2" | md5
2ab96390c7dbe3439de74d0c9b0b1767
Same input, same digest, every single time. That determinism is what lets a server check a password without ever storing it: hash what the user typed, compare it to the stored hash. It is also the exact property an attacker leans on, which is where this goes wrong.
Where it goes wrong
Three failure modes, ordered by how often they turn into an actual breach.
"We base64 the passwords"
Base64 is not encryption. There is no key to protect. Anyone with database access, a stray backup, or one leaked log line runs a single command and reads every password in plaintext. Storing Base64 and calling it encrypted is storing plaintext with a costume on.
Unsalted SHA-256 for passwords
Better, still broken. SHA-256 is a general-purpose hash designed to be fast, which is precisely why it is wrong for passwords. A single high-end GPU computes billions of SHA-256 hashes per second. Two consequences follow:
- Rainbow tables. Because the hash is deterministic and unsalted,
hunter2always hashes tof52f...f6c7. Precompute the common passwords once, look them up forever. Every unsalted database in the world shares the same lookup table. - Brute force. Even with no precomputed table, a cracker walks through billions of guesses a second against your stolen hashes, and most user passwords fall in minutes.
A salt, a unique random value stored next to each hash, defeats the precomputation and makes two users with the same password get different hashes. It does not touch the speed problem, and speed is the problem.
Treating a hash as a secret
A hash is not a secret. It is a deterministic function of its input, so if the set of possible inputs is small, you hash the whole set and match. Hashing an email address to "anonymize" it in analytics accomplishes nothing: an attacker hashes their own mailing list and matches every row back to a name. The same holds for phone numbers, ZIP codes, and four-digit PINs. A hash conceals a value only when that value is long and unpredictable to begin with.
Seeing it yourself
Open Hash text and paste hunter2 into the Text box. There is no button; every digest recomputes live as you type. The SHA256 row reads f52fbd32...a3f6c7 and the MD5 row reads 2ab96390...b1767, matching the terminal output above to the character, because a hash is a hash whether a shell or your browser computes it.
Now change the Digest Encoding dropdown from Hexadecimal to Base64. The SHA-256 value becomes 9S+9MrKzuG/4jvbEkGKChfSCrxXdyylUH5S89Saj9sc=. Nothing was rehashed. Those are the identical 32 bytes wearing different clothes, the encoding-versus-hashing distinction sitting in one control. Retype the same input and every row is byte-for-byte what it was, the determinism that makes hashing verifiable and, for passwords, dangerous.

What to actually do
The reason bcrypt, scrypt, and Argon2 exist is that SHA-256 is too fast. A password hash needs to be slow enough to make bulk guessing painful, yet fast enough to check once at login. bcrypt turns that into a dial called the cost factor. Cost 12 means 2^12, or 4096, internal rounds, and each step up the number doubles the work. Tune it until one hash takes a noticeable fraction of a second on your server, and the GPU that ran billions of SHA-256 per second now manages a few thousand bcrypt guesses per second.
bcrypt also salts for you. Hash hunter2 twice in Bcrypt and the results differ, thanks to the per-hash salt baked into each string:
$2b$12$je8XYYsSrDMuCVeokcpwOuAt..5Lh1bmvVC6qC9JyApSg6S0tLxCW
$2b$12$I8N3iLsm46wP.d9rMFMANeubGi0Jarx5HHN9fCrqJR3fABjamlslW
Read left to right: $2b$ is the algorithm version, 12 is the cost factor from the tool's Salt count control, the next 22 characters are the salt, and the remainder is the hash. Everything the verifier needs travels inside that one string.
A short checklist to keep the three straight:
- Encode for transport, never for secrecy. Base64 moves bytes through text channels and guards nothing.
- Encrypt when you need the data back and only key-holders should read it.
- Hash passwords with bcrypt, scrypt, or Argon2, never plain SHA-256 or MD5, always with a per-password salt. These slow hashes salt automatically.
- Never store a hash and call it hidden. If the input is guessable, store a genuine random value instead, minted with Secret generator.
- A slow hash still cannot save "hunter2". Run real candidate passwords through Password strength analyser before you trust any hashing scheme to protect them.