Encoding vs Encryption vs Hashing (for Everyday Developers)
These three terms are frequently confused, even among seasoned developers who should be well-versed in their distinctions. However, it is crucial to recognize that each of these terms addresses fundamentally different issues within the realm of software development. Misapplying any one of them can lead to significant security vulnerabilities in your application, potentially compromising its integrity and the safety of its users. Understanding the precise context and implications of each term is essential for maintaining robust security practices in your development process.
This post will help you understand key differences with practical Node.js examples. It clarifies concepts and enables immediate application, making it suitable for both experienced developers and newcomers to effectively integrate into their workflow and projects.
1. The Big Picture
Before diving into each one, here’s a quick orientation:
| Concept | One-line purpose | Reversible? | Requires a key? | Primary use |
|---|---|---|---|---|
| Encoding | Make data portable/transferable | ✅ Yes | ❌ No | Data transport, serialisation |
| Encryption | Keep data confidential | ✅ Yes | ✅ Yes | Protecting sensitive data at rest or in transit |
| Hashing | Fingerprint data (verify integrity) | ❌ No | ❌ No | Passwords, checksums, data integrity |
⚠️ The #1 mistake developers make: treating encoding as a form of security. It isn’t. Anyone can decode Base64 in two seconds.
Think of it this way:
- Encoding is like translating a sentence into another language — anyone with a dictionary can read it.
- Encryption is like writing a letter in a secret code — only someone with the codebook (key) can read it.
- Hashing is like running meat through a grinder — you can confirm the result, but you can’t reconstruct the original.
2. Encoding — Make Data Travel Safely
What it is
Encoding converts data from one format to another so it can be safely stored or transmitted — then converted back. It has nothing to do with secrecy. The goal is compatibility, not confidentiality.
When you need it
- Sending binary data (images, files) over text-based protocols like HTTP or email
- Embedding data in URLs
- Serialising structured data as JSON for an API response
Common encoding schemes
| Scheme | What it does | Example use case |
|---|---|---|
| Base64 | Encodes binary as ASCII text | Embedding images in HTML/CSS, JWTs |
| URL encoding | Escapes special characters in URLs | Query strings: hello world → hello%20world |
| JSON | Serialises objects to a string | REST API request/response bodies |
| Hex | Encodes bytes as hexadecimal characters | Displaying hash digests, colour codes |
| UTF-8 | Encodes Unicode characters as bytes | Almost everything on the web |
Base64 Encoding Example (Node.js)
const original = "Hello, Marcell! This is sensitive-looking text.";
// Encode to Base64
const encoded = Buffer.from(original).toString("base64");
console.log("Encoded:", encoded);
// SGVsbG8sIE1hcmNlbGwhIFRoaXMgaXMgc2Vuc2l0aXZlLWxvb2tpbmcgdGV4dC4=
// Decode back to original — trivially easy
const decoded = Buffer.from(encoded, "base64").toString("utf8");
console.log("Decoded:", decoded);
// Hello, Marcell! This is sensitive-looking text.
URL Encoding Example (Node.js)
const params = new URLSearchParams({
name: "Marcell Ciszek",
company: "Manta Marine",
message: "Hello & welcome!",
});
console.log(params.toString());
// name=Marcell+Ciszek&company=Manta+Marine&message=Hello+%26+welcome%21
// Decode it back
console.log(decodeURIComponent("Hello+%26+welcome%21"));
// Hello & welcome!
❌ Never do this:
// This is NOT security — it's just encoding const "hidden" = Buffer.from(apiKey).toString("base64");Every developer on the internet knows how to reverse Base64. It offers zero protection.
3. Encryption — Keep Data Secret
What it is
Encryption transforms data into an unreadable ciphertext using a key. Only someone with the correct key can reverse the process (decryption) and read the original data. Without the key, the data is meaningless.
The two main types
| Type | How it works | Key structure | Speed | Best for |
|---|---|---|---|---|
| Symmetric | Same key encrypts and decrypts | Single shared key | Fast | Encrypting large data (files, databases) |
| Asymmetric | Public key encrypts, private key decrypts | Key pair (public + private) | Slower | Key exchange, digital signatures, TLS |
Symmetric Encryption — AES-256-GCM (Node.js)
AES-256-GCM is the gold-standard symmetric algorithm. GCM (Galois/Counter Mode) provides both encryption and authentication in one step.
const crypto = require("crypto");
const ALGORITHM = "aes-256-gcm";
// In a real app, store this key securely (e.g. environment variable or secrets manager)
const KEY = crypto.randomBytes(32); // 256-bit key
const IV = crypto.randomBytes(12); // 96-bit IV recommended for GCM
function encrypt(plaintext) {
const cipher = crypto.createCipheriv(ALGORITHM, KEY, IV);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
// Auth tag verifies the data wasn't tampered with
const authTag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString("hex"),
authTag: authTag.toString("hex"),
iv: IV.toString("hex"),
};
}
function decrypt({ ciphertext, authTag, iv }) {
const decipher = crypto.createDecipheriv(
ALGORITHM,
KEY,
Buffer.from(iv, "hex")
);
decipher.setAuthTag(Buffer.from(authTag, "hex"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertext, "hex")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
const encrypted = encrypt("Super secret message");
console.log("Encrypted:", encrypted);
const decrypted = decrypt(encrypted);
console.log("Decrypted:", decrypted); // Super secret message
Asymmetric Encryption — RSA (Node.js)
Asymmetric encryption is commonly used to securely exchange keys or encrypt small pieces of data. You share your public key freely — anyone can use it to encrypt data. Only you (with your private key) can decrypt it.
const crypto = require("crypto");
// Generate a key pair (do this once and store them securely)
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
function encryptWithPublicKey(data) {
return crypto.publicEncrypt(publicKey, Buffer.from(data)).toString("base64");
}
function decryptWithPrivateKey(encrypted) {
return crypto
.privateDecrypt(privateKey, Buffer.from(encrypted, "base64"))
.toString("utf8");
}
const message = "Only the private key owner can read this";
const encrypted = encryptWithPublicKey(message);
console.log("Encrypted:", encrypted);
const decrypted = decryptWithPrivateKey(encrypted);
console.log("Decrypted:", decrypted); // Only the private key owner can read this
Key management rules
| Rule | Why it matters |
|---|---|
| Never hardcode keys in source code | Source code is often shared or committed to git |
| Rotate keys periodically | Limits damage if a key is ever compromised |
| Use a secrets manager (Vault, AWS KMS) | Centralised, auditable key storage |
| Generate keys with sufficient length | Short keys are vulnerable to brute-force |
| Separate keys per environment | A dev key should never unlock production data |
4. Hashing — Prove Something Without Revealing It
What it is
A hash function takes any input and produces a fixed-length output (the hash or digest). It is a one-way operation — you cannot reconstruct the original input from the hash. The same input always produces the same hash, and even a single character change produces a completely different hash (this is called the avalanche effect).
Properties of a good cryptographic hash function
| Property | What it means |
|---|---|
| Deterministic | Same input always → same output |
| Fast to compute | Efficient to run forward |
| Pre-image resistant | Cannot reverse a hash back to its input |
| Collision resistant | Near impossible to find two inputs with the same hash |
| Avalanche effect | Tiny input change → completely different hash |
SHA-256 Hashing Example (Node.js)
const crypto = require("crypto");
function sha256(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
console.log(sha256("hello"));
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
console.log(sha256("hellо")); // Notice: different letter (Cyrillic 'о')
// 7e8edb3eb2f54f84de0a4a55b8c6a27a8c7a5d5fce22...
// Completely different hash — avalanche effect in action
console.log(sha256("hello")); // Same as first — always deterministic
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
HMAC — Hashing with a Secret Key (Node.js)
HMAC (Hash-based Message Authentication Code) combines hashing with a secret key. It’s used to verify that a message came from a trusted source and wasn’t tampered with — this is how JWTs are signed.
const crypto = require("crypto");
const SECRET_KEY = process.env.HMAC_SECRET || "super-secret-key";
function createHmac(data) {
return crypto
.createHmac("sha256", SECRET_KEY)
.update(data)
.digest("hex");
}
function verifyHmac(data, receivedHmac) {
const expected = createHmac(data);
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedHmac)
);
}
const payload = JSON.stringify({ userId: 42, role: "admin" });
const signature = createHmac(payload);
console.log("Signature:", signature);
// Later — verify the payload wasn't tampered with
console.log("Valid?", verifyHmac(payload, signature)); // true
const tamperedPayload = JSON.stringify({ userId: 42, role: "superadmin" });
console.log("Valid?", verifyHmac(tamperedPayload, signature)); // false
Popular hashing algorithms compared
| Algorithm | Output size | Speed | Use for passwords? | Status |
|---|---|---|---|---|
| MD5 | 128-bit | Very fast | ❌ No | Broken — don’t use for security |
| SHA-1 | 160-bit | Fast | ❌ No | Deprecated for security use |
| SHA-256 | 256-bit | Fast | ❌ No (too fast) | ✅ Good for checksums, HMAC, integrity |
| SHA-3 | 256/512-bit | Fast | ❌ No | ✅ Good for checksums, integrity |
| bcrypt | 184-bit | Intentionally slow | ✅ Yes | ✅ Industry standard for passwords |
| Argon2id | Variable | Intentionally slow | ✅ Yes | ✅ Current best practice for passwords |
💡 Key insight: For passwords, you want a slow algorithm. Fast hashing lets attackers check billions of guesses per second. bcrypt and Argon2 are deliberately designed to be expensive to compute.
5. How Passwords Are Actually Stored
Why you should never store plaintext passwords
If your database is ever leaked (and breaches happen to everyone), plaintext passwords expose all your users instantly. Even using a fast hash like SHA-256 is dangerous because attackers use precomputed rainbow tables and can crack common passwords in milliseconds.
The right approach: slow hashing + salting
A salt is a random string added to the password before hashing. This ensures two users with the same password get completely different hashes, and defeats rainbow table attacks.
bcrypt Example (Node.js)
const bcrypt = require("bcrypt");
const SALT_ROUNDS = 12; // Higher = slower = more secure (10–14 is typical)
// --- Registration ---
async function registerUser(plainPassword) {
// bcrypt automatically generates and embeds the salt
const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
// Store `hash` in your database — NEVER the plain password
console.log("Stored hash:", hash);
return hash;
}
// --- Login ---
async function loginUser(plainPassword, storedHash) {
const isMatch = await bcrypt.compare(plainPassword, storedHash);
if (isMatch) {
console.log("✅ Password correct — user authenticated");
} else {
console.log("❌ Wrong password");
}
return isMatch;
}
// Simulate registration and login
const storedHash = await registerUser("mySecurePassword123!");
await loginUser("mySecurePassword123!", storedHash); // ✅ correct
await loginUser("wrongPassword", storedHash); // ❌ wrong
Argon2 Example (Node.js)
Argon2id is the current winner of the Password Hashing Competition and is recommended by OWASP as the first choice for new applications.
const argon2 = require("argon2");
// --- Registration ---
async function register(plainPassword) {
const hash = await argon2.hash(plainPassword, {
type: argon2.argon2id, // Resistant to both GPU and side-channel attacks
memoryCost: 64 * 1024, // 64 MB of memory required
timeCost: 3, // Number of iterations
parallelism: 1,
});
// Store hash in your database
return hash;
}
// --- Login ---
async function login(plainPassword, storedHash) {
const isValid = await argon2.verify(storedHash, plainPassword);
return isValid;
}
const hash = await register("mySecurePassword123!");
console.log(await login("mySecurePassword123!", hash)); // true
console.log(await login("wrongPassword", hash)); // false
bcrypt vs Argon2 — which should you use?
| Factor | bcrypt | Argon2id |
|---|---|---|
| Maturity | Very mature (1999) | Newer (2015), OWASP #1 recommendation |
| Memory hardness | ❌ No | ✅ Yes (harder to parallelise on GPUs) |
| Configuration flexibility | Limited (just cost factor) | High (memory, time, parallelism) |
| Library support | Excellent | Good and growing |
| Best for | Existing apps, widespread ecosystem support | New applications |
For new projects, prefer Argon2id. For existing projects already using bcrypt, it remains secure — no need to migrate unless you want to.
6. Common Mistakes (and Fixes)
| Mistake | Why it’s bad | What to do instead |
|---|---|---|
| Using Base64 to “hide” API keys or passwords | Trivially reversible in seconds | Use environment variables or a secrets manager |
| Storing plaintext passwords | A single DB leak exposes every user | Hash with bcrypt or Argon2id |
| Using MD5 or SHA-1 for passwords | Far too fast — billions of guesses/second on GPUs | Use bcrypt, Argon2id, or scrypt |
| Using fast hashes (SHA-256) for passwords | Still too fast for password storage | Use bcrypt or Argon2id |
| Hardcoding encryption keys in source code | Keys end up in git history, CI logs, etc. | Load from env vars or secrets manager |
| Reusing the same IV/nonce across encryptions | Breaks the security of GCM and CTR modes | Always generate a fresh random IV per operation |
| Rolling your own crypto | Subtle bugs invalidate all security guarantees | Use established libraries (node:crypto, bcrypt, argon2) |
Not using timingSafeEqual for comparing secrets | Timing attacks can leak information bit by bit | Always use crypto.timingSafeEqual() for secret comparison |
| Encrypting passwords instead of hashing them | If your key leaks, all passwords are exposed | Passwords should be hashed, never encrypted |
| Using the same key for everything | One compromised key breaks everything | Use separate keys per purpose and environment |
7. Real-World Scenarios
| Scenario | Right tool | Reasoning |
|---|---|---|
| Store a user’s password | Hashing (bcrypt / Argon2id) | Must not be reversible; slow hash defeats brute-force |
| Store a user’s credit card number | Encryption (AES-256-GCM) | Must be retrievable for charging; needs secrecy |
| Verify a file wasn’t corrupted in transit | Hashing (SHA-256) | Compare digest before and after transfer |
| Embed an image in an HTML email | Encoding (Base64) | Email protocols are text-based |
| Sign a JWT token | HMAC-SHA256 | Proves the token was issued by your server |
| Send an encrypted message to a user | Asymmetric encryption (RSA / ECDH) | Recipient’s public key encrypts, only their private key decrypts |
| Cache a sensitive config value | Encryption (AES-256) | Needs to be retrieved; encryption + key management |
| Build a URL with special characters | URL encoding | Safe transport over HTTP |
| Detect duplicate user-uploaded files | Hashing (SHA-256) | Same file always produces the same hash |
| Protect data at rest in a database column | Encryption (AES-256-GCM) | Data must be readable by the application |
8. When to Use What (Quick Reference)
| If you need to… | Use |
|---|---|
| Make data safe to send over a network | Encoding |
| Keep data secret from others | Encryption |
| Verify data without storing it | Hashing |
| Store a password | bcrypt / Argon2id |
| Verify a file’s integrity | SHA-256 |
| Sign a token or message | HMAC-SHA256 |
| Send a secret to someone without a shared key | Asymmetric encryption (RSA) |
| Encode binary data as text | Base64 |
| Pass parameters safely in a URL | URL encoding |
9. The Mental Model
Is the data meant to be read by anyone?
→ Yes → Encoding (Base64, URL, JSON)
Is the data secret, but you need to retrieve it later?
→ Yes → Encryption (AES-256-GCM for symmetric, RSA for asymmetric)
Is the data something you only need to verify, not retrieve?
→ Yes → Hashing (SHA-256 for integrity, bcrypt/Argon2 for passwords)
| ❌ Wrong | ✅ Right |
|---|---|
Buffer.from(password).toString("base64") | await bcrypt.hash(password, 12) |
sha256(password) stored in DB | await argon2.hash(password) stored in DB |
Hardcoded encryption key in config.js | Key loaded from process.env.SECRET_KEY |
| MD5 for password hashing | Argon2id with memory cost ≥ 64 MB |
| Same IV reused across encryptions | Fresh crypto.randomBytes(12) per operation |
If you take one thing away from this post: encoding is not security, hashing is not encryption, and passwords should never be decryptable. Get those three rules right and you’ll avoid the most common security mistakes in web development.
Conclusion
Encoding, encryption, and hashing are not interchangeable — they solve fundamentally different problems, and reaching for the wrong tool doesn’t just lead to broken code, it leads to broken security.
Encoding is about compatibility, not confidentiality. Use it whenever you need data to travel safely across a medium that wasn’t designed for raw bytes — an email body, a URL query string, or an HTML attribute. Base64 and URL encoding are workhorses of the web. Just remember: anyone can reverse them instantly. Never use encoding to hide something.
Encryption is about secrecy with recoverability. When you need to protect data but also need to read it again later — a stored credit card, an API token, a config value — encryption is the right choice. AES-256-GCM gives you confidentiality, integrity, and tamper detection in one primitive. The security lives entirely in the key, so key management is everything: use environment variables, rotate keys, and never hardcode them.
Hashing is about verification without storage. When you don’t need to recover the original value — you only need to confirm something matches — hashing is the tool. SHA-256 is fast and suitable for checksums and file integrity. For passwords, fast hashes are a liability; use bcrypt or Argon2id, which are intentionally slow and memory-hard, making brute-force attacks prohibitively expensive.
The real lesson isn’t just knowing what each tool does — it’s knowing what each one doesn’t do. Encoding doesn’t protect. Encryption can’t verify a password without exposing your key. A fast hash won’t protect your users if your database leaks. Each tool has a lane, and mixing them up is one of the most common sources of real-world security vulnerabilities.
If you take three rules into your next project:
- Encoding is not security — don’t hide secrets behind Base64.
- Passwords must not be decryptable — hash them with bcrypt or Argon2id, always.
- Encryption is only as strong as your key management — treat your keys like passwords.
Get those three right, and you’ll have sidestepped the majority of credential leaks, authentication bypasses, and data exposure bugs that make the news every year.