Authenticating means answering "who are you?"
Authentication checks the identity of whoever makes the request. It almost always begins with a username/password pair, and ends in a token that the client sends on every subsequent request.
Never store passwords in plain text
If your database stores "hunter2" and someone steals it, they already have all
your users' passwords (and, since people reuse them, also those of their email
and bank). The rule is absolute: a password is never stored, its hash is
stored instead.
A hash is the result of a one-way function: it is easy to compute
hash("hunter2"), but infeasible to recover "hunter2" from the hash.
When logging in, you re-hash what the user types and compare hashes.
The hash needs "salt"
If every hash uses the same plain function, two people with the same password will have the same hash, and an attacker can precompute tables (rainbow tables) with the hashes of millions of common passwords.
The salt is a random value, different for each user, that is mixed with the password before hashing. This way, two identical passwords produce different hashes, and precomputed tables become useless.
// Conceptual with bcrypt (NOT in this sandbox):
import bcrypt from "bcrypt";
// On signup: generate salt + hash (bcrypt includes it inside the hash).
const hash = await bcrypt.hash(password, 10); // 10 = cost/rounds
// On login: compare without "un-hashing" anything.
const ok = await bcrypt.compare(enteredPassword, hash);
bcrypt is slow on purpose (the "cost" controls how many rounds). That slowness is desirable: it slows down brute-force attacks.
In the exercises we don't have bcrypt in the sandbox, so we'll simulate a simple deterministic hash. The idea (one-way + salt) is the same.
JWT tokens
After validating the password, you don't want to ask for it on every request. You issue a token: a signed credential that the client stores and resends.
A JWT (JSON Web Token) has three parts separated by dots:
header.payload.signature
- Header: the signing algorithm (e.g.
HS256). - Payload: the data (claims):
{ sub: "u1", role: "admin", exp: ... }. - Signature: computed with a secret key that only the server knows.
Header and payload are in Base64 (readable by anyone: don't encrypt anything sensitive there). What protects the token is the signature: if someone tampers with the payload, the signature no longer matches and the server rejects the token.
// Conceptual with jsonwebtoken (NOT in this sandbox):
import jwt from "jsonwebtoken";
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, {
expiresIn: "1h",
});
const payload = jwt.verify(token, SECRET); // throws if the signature is invalid
In the exercises we'll simulate the signature with a deterministic
sign(text, secret)function (in production it would be HMAC-SHA256). The token isbody.signature; when verifying, the signature is recomputed and, if the payload was tampered with, it no longer matches and is rejected. That way you practice the real integrity guarantee, not just decoding Base64.
The complete flow
POST /loginwith username and password.- The server validates the password against the stored hash.
- If correct, it signs a token and returns it.
- The client stores it and, on every request, sends it in the header:
Authorization: Bearer <token>
- The server verifies the token on every request and knows who asks for what.
Examples
Signed token: altering the payload invalidates the signature
// Didactic deterministic "signature" (in production: HMAC-SHA256).
function sign(text, secret) {
let h = 0;
for (const c of text + "|" + secret) h = (Math.imul(h, 31) + c.charCodeAt(0)) | 0;
return (h >>> 0).toString(16);
}
function createToken(payload, secret) {
const body = btoa(JSON.stringify(payload));
return body + "." + sign(body, secret);
}
function verifyToken(token, secret) {
const [body, signature] = token.split(".");
if (!body || signature !== sign(body, secret)) return null; // tampered
return JSON.parse(atob(body));
}
const token = createToken({ sub: "u1", role: "user" }, "s3cr3t");
console.log(verifyToken(token, "s3cr3t").role); // "user"
// An attacker changes the payload, but without the secret can't re-sign:
const [, signature] = token.split(".");
const fake = btoa(JSON.stringify({ sub: "u1", role: "admin" })) + "." + signature;
console.log(verifyToken(fake, "s3cr3t")); // null (rejected)