Passwords, Hashing, JWT
bcrypt, argon2, what's in a JWT, why you should rotate.
Three words sound similar and mean very different things: encoding, hashing, and encryption. Mix them up and you ship a security bug. Mix them up around passwords or JWTs and you ship a front-page-news security bug. Let's settle this once.
Encoding vs hashing vs encryption
- Encoding = format change. Reversible by anyone. Examples:
base64, URL-encoding, hex. Not security.If your password is base64-encoded in the database, it's plaintext. - Hashing = one-way function. Input → fixed-size digest. Cannot be reversed (without brute force). Examples: SHA-256, bcrypt, argon2id. Used for verifying without storing.
- Encryption = two-way function with a key. Reversible only if you have the key. Examples: AES-GCM, RSA. Used when you need to recover the original later.
// Encoding - reversible, NOT security
btoa("hello") // "aGVsbG8="
atob("aGVsbG8=") // "hello"
// Hashing - one way
sha256("hunter2") // 'f52fb...' - cannot be undone
// Encryption - two way with a key
encrypt("secret", key) // ciphertext, recoverable only with keyHow to store passwords: hash with a slow function and salt
Never store plaintext. Never store an MD5 or SHA-256 of the password either; those are too fast and get cracked by brute force. Use a deliberately slow, salted, memory-hard hash function:
- argon2id - current best-practice. Memory-hard, resistant to GPU/ASIC attacks. Use it for new systems.
- bcrypt - older but still solid. Used everywhere. Cost factor
12is a sensible default in 2026. - scrypt - also memory-hard, fine but less common than argon2id.
import argon2 from "argon2";
// signup
const hash = await argon2.hash(password, { type: argon2.argon2id });
db.users.insert({ email, password_hash: hash });
// login
const ok = await argon2.verify(user.password_hash, password);
if (!ok) throw new Error("invalid credentials");Note: argon2 (and bcrypt) include the salt inside the hash string. You don't need a separate salt column. Just store the hash.
JWT anatomy: three base64url chunks
A JWT is a string with two dots. Each section is base64url-encoded JSON:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWxpY2UiLCJpYXQiOjE3MTY1OTAwMDB9.aF4nF...
# split on .
# header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
# payload: eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWxpY2UiLCJpYXQiOjE3MTY1OTAwMDB9
# signature: aF4nF...Decode header and payload (they're just JSON):
{
"alg": "HS256",
"typ": "JWT"
}{
"sub": "123",
"name": "Alice",
"iat": 1716590000
}The signature is the part that matters. It's an HMAC (or RSA/ECDSA) over base64url(header) + "." + base64url(payload), using a secret only the server knows. Anyone can read the payload. Only the server can produce a valid signature. That's the whole trick.
Decoding is not verifying
This is the most common JWT mistake. jwt.decode() just unpacks the base64 and returns the JSON. It does not check the signature. If you read payload.userId from a decoded JWT and trust it, anyone can hand-craft a token with { "userId": "admin" }and you'll trust it.
const payload = jwt.decode(token); // no signature check!
const userId = payload.sub;
// userId is whatever the attacker wants.const payload = jwt.verify(token, secret, { algorithms: ["HS256"] });
const userId = payload.sub;
// Only valid if the signature is correct.algorithms option. Without it, some libraries accept whatever the token claims, including the infamous alg: none.The alg:none attack
The JWT spec defines a special algorithm value none meaning "no signature." Some old libraries, if you call verify() without pinning algorithms, would happily accept a token like:
// header
{ "alg": "none", "typ": "JWT" }
// payload
{ "sub": "admin", "role": "admin" }
// signature
(empty)Attacker constructs this, sends it. Library says "alg is none, so I won't check the signature." You read payload.role. They're an admin now.
Defense: always pin the algorithm list when verifying. Modern libraries reject alg: noneby default but don't rely on that.
Key rotation
Signing keys leak. When they do, you need to rotate without invalidating every token at once. Two main patterns:
- Keep multiple keys live. Header includes a
kid(key id). Verifier maintains a small map ofkid → key. To rotate, add a new key with a new kid, stop signing with the old, but keep verifying old tokens until they expire. - JWKS endpoint. Providers like Auth0/Cognito host a
.well-known/jwks.jsonURL with the public keys. Your service fetches it, caches it, and uses thekidfrom each token's header to pick the right key.
Try it: decode a JWT yourself
Below is a real-looking sample JWT. The code splits, base64-decodes each part, and prints the JSON. Notice that it does not verify anything. That's the point of the exercise.
HttpOnly; Secure; SameSite=Lax cookie if it's the user's session. Then the browser attaches it automatically and JS can't read it. Storing JWTs in localStorage is convenient but leaves them readable by any XSS that gets injected. Cookies + same-site + a CSRF token on sensitive POSTs is the modern answer.Quiz
Which of these is appropriate for storing user passwords?
Recap
- Encoding = format. Hashing = one-way. Encryption = two-way with key. Three different things.
- Hash passwords with argon2id (or bcrypt). Salt is included in the hash. Never plaintext, never plain SHA.
- A JWT is
base64url(header).base64url(payload).signature. The signature is what makes it trustworthy. - Decoding is not verifying. Always call
verify(), pin algorithms, rejectalg: none. - Rotate keys with
kidin the header. Use short expiries and refresh tokens.