webdev.complete
🔐 Authentication & Security
🌐The Web Beneath
Lesson 61 of 117
25 min

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.
js
// 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 key
Base64 is encoding. JWT payloads are base64-encoded, not encrypted. Anyone can decode them. We'll see this below.

How 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.
If you learned bcrypt cost 12, update that
"bcrypt with cost 12" was standard advice for years and is still repeated everywhere. OWASP now scopes bcrypt to legacy systems where argon2 and scrypt are unavailable, with a floor of 10 rather than a target of 12. An existing bcrypt deployment is not an emergency; a new one is a choice you should be able to justify.
server/auth.ts
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");
bcrypt silently truncates at 72 bytes
If you are stuck with bcrypt, cap password length at 72 bytes and say so in your UI. Most implementations ignore everything past that byte, so a user with a 100-character passphrase has less entropy than they think. Do not "fix" this by pre-hashing with SHA-512 and feeding the result to bcrypt: that reintroduces null-byte truncation and makes you vulnerable to password shucking.

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:

bash
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWxpY2UiLCJpYXQiOjE3MTY1OTAwMDB9.aF4nF...

# split on .
# header:    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
# payload:   eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWxpY2UiLCJpYXQiOjE3MTY1OTAwMDB9
# signature: aF4nF...

Decode header and payload (they're just JSON):

header
{
  "alg": "HS256",
  "typ": "JWT"
}
payload
{
  "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.

bad
const payload = jwt.decode(token);    // no signature check!
const userId = payload.sub;
// userId is whatever the attacker wants.
good
const payload = jwt.verify(token, secret, { algorithms: ["HS256"] });
const userId = payload.sub;
// Only valid if the signature is correct.
Always pin algorithms
Always pass the 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:

json
// 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 of kid → 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.json URL with the public keys. Your service fetches it, caches it, and uses the kid from each token's header to pick the right key.
Set short expiries on JWTs (15 minutes is common) and use refresh tokens for long-lived sessions. Short expiries make rotation easy and limit the damage from a stolen token.

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.

// Sample JWT (header.payload.signature)
const token =
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im0xIn0." +
  "eyJzdWIiOiI5OTciLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzE2NTkwMDAwLCJleHAiOjE3MTY1OTM2MDB9." +
  "fakeSignatureBytes_NotVerifiedHere";

function base64urlDecode(str) {
  // base64url -> base64
  const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((str.length + 3) % 4);
  return atob(b64);
}

const [h, p, s] = token.split(".");

console.log("HEADER:");
console.log(JSON.parse(base64urlDecode(h)));

console.log("PAYLOAD:");
console.log(JSON.parse(base64urlDecode(p)));

console.log("SIGNATURE (opaque bytes):");
console.log(s);

console.log("---");
console.log("Note: we just READ the payload. We did NOT verify the signature.");
console.log("If this were a real auth check, you would now call jwt.verify(token, secret).");

Storage tip
Where to put a JWT? In a 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

Quiz1 / 4

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, reject alg: none.
  • Rotate keys with kid in the header. Use short expiries and refresh tokens.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.