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:
OWASP's Password Storage Cheat Sheet gives specific parameters rather than vague advice, and the specifics have moved. In order of preference:
- argon2id - the recommendation. Minimum 19 MiB of memory, 2 iterations, 1 degree of parallelism. Memory-hard, so a GPU farm gains far less than it does against a CPU-only hash.
- scrypt - the fallback when argon2id is not available. Minimum cost
2^17, block size 8, parallelization 1. - bcrypt - for legacy systems only, at a work factor of 10 or more. It is no longer a recommended choice for new code.
- PBKDF2 - only if you need FIPS-140 compliance. HMAC-SHA-256 with 600,000 iterations.
import argon2 from "argon2";
// signup: memoryCost is in KiB, so 19456 KiB = 19 MiB
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
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: none by 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 at 19 MiB, t=2, p=1. The salt is included in the hash. Never plaintext, never plain SHA, and bcrypt only for legacy systems.
- 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.