Quick Answer

Encoding changes format and anyone can reverse it. Hashing is one-way and used for passwords and integrity. Encryption is two-way with a key, used to protect data you need to read back.

The three, distinguished

Encoding changes representation, not secrecy. Base64 turns binary into text so it survives systems that expect text. Anyone can decode it, instantly, with no key. It provides zero security, and treating a base64 string as protected is a real and common mistake — a JWT payload is base64, which is why it must never contain secrets.

Hashing is one-way. The same input always produces the same output, and there is no way back:

sha256("password")  -> 5e884898da28047151d0e56f8dc62927...
sha256("passworD")  -> 9e78de733c6a51c0cc954c1d956d8929...

One changed character produces a completely different hash. Used for password storage and integrity checking.

Encryption is two-way with a key. Encrypt to protect, decrypt with the key to read. Used when you need the original back — messages, files, database fields.

The rule that follows: hash passwords, encrypt data. You never need a user's original password, only to check whether a submitted one matches.

Why a fast hash is wrong for passwords

This is the part that surprises people, and it is measurable.

SHA-256 is designed to be fast, and speed is the problem. Timing it in single-threaded Python:

200,000 sha256 hashes in 2.16s -> ~92,685 guesses/sec on one core

That is Python, on one core. Purpose-built cracking hardware does orders of magnitude better. An attacker with your database of SHA-256 hashes can test enormous numbers of candidate passwords.

Password hashing functions are deliberately slow:

1 pbkdf2 hash (600,000 iterations): 1.328s
-> fewer than one guess per second, per core

Roughly a second per attempt. Legitimate login is unaffected — nobody notices a second on sign-in — while brute force becomes impractical.

Use bcrypt, scrypt, Argon2 or PBKDF2. Never plain SHA-256 or MD5 for passwords. Argon2 is the current recommendation, and bcrypt remains perfectly acceptable.

Salt: why identical passwords must hash differently

Without a salt, everyone using "password123" has the same hash. An attacker who cracks one has cracked all of them, and precomputed tables make lookups instant.

A salt is a random value stored alongside the hash and mixed in:

same password, salt 1: 37F397637BF70A85C55BACB0...
same password, salt 2: 24B117D866FD10108FF86AB2...

Identical password, completely different hashes. Precomputed tables become useless, and each password must be attacked individually.

The salt is not secret — it is stored with the hash and that is fine. Its purpose is uniqueness, not concealment.

In practice you do not manage this yourself. bcrypt and Argon2 generate a salt automatically and embed it in the output string, so verification just works. Rolling your own salting scheme is where people introduce bugs.

Symmetric and asymmetric encryption

Symmetric uses one key for both directions. AES is the standard. It is fast and suitable for bulk data — the problem is distributing the key to whoever needs it.

Asymmetric uses a pair: encrypt with the public key, decrypt with the private one. RSA and elliptic-curve algorithms. It solves key distribution, since the public key can be shared openly, but it is much slower.

Real systems use both. HTTPS uses asymmetric cryptography during the handshake purely to agree a shared symmetric key, then encrypts the actual traffic symmetrically — the best of both. See SSL/TLS certificates.

The other everyday use is signing, which is asymmetric in reverse: sign with the private key, and anyone can verify with the public key. That is how SSH keys and code signing work.

HMAC: proving a message was not tampered with

A hash proves integrity only if the attacker cannot recompute it. HMAC adds a shared secret so they cannot.

key = b"shared-secret"
msg = b'{"amount":100}'
sig = hmac.new(key, msg, hashlib.sha256).hexdigest()

hmac.compare_digest(sig, recompute(msg))                 # True
hmac.compare_digest(sig, recompute(b'{"amount":9999}'))  # False

Changing the amount invalidates the signature, and only someone with the key can produce a valid one. This is exactly how webhook signatures work.

Note compare_digest rather than ==. A normal comparison exits at the first differing byte, so the time it takes leaks how much of the signature was correct — enough, over many attempts, to reconstruct it. Constant-time comparison removes that.

The overarching rule: use established libraries, never invent your own cryptography. Every example here is a standard primitive used correctly, and correct use is where the difficulty actually lies.

Frequently Asked Questions

Is base64 a form of encryption? No. It is encoding, reversible by anyone with no key. It provides no security whatsoever, which is why a JWT payload must never contain secrets.
Why not use SHA-256 for passwords? It is designed to be fast, which helps attackers. Measured in Python it managed about 92,000 guesses per second on one core. Use a deliberately slow function such as bcrypt or Argon2.
Does the salt need to be secret? No. It is stored alongside the hash. Its purpose is making identical passwords hash differently, which defeats precomputed lookup tables.
Should I ever encrypt passwords instead of hashing them? No. Encryption is reversible, so a compromised key exposes every password. You never need the original, only to verify a submitted one, which hashing does.
What is HMAC used for? Proving a message came from someone holding a shared secret and was not modified. Webhook signatures are the most common example developers encounter.