DevPath · Learn to code ESPTEN

Authentication and security

Authentication: passwords and tokens

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 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 is body.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

  1. POST /login with username and password.
  2. The server validates the password against the stored hash.
  3. If correct, it signs a token and returns it.
  4. The client stores it and, on every request, sends it in the header:
Authorization: Bearer <token>
  1. 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)
Put this into practice

DevPath is a hands-on course: you read the theory here; in the app you put it into practice with exercises that really run, offline.

Start free in the app →
Authorization: require a token and check roles →