Full-Stack Development · Tutorial 5 of 5
Authentication Basics: logins, sessions and protected routes
By the FUTEAC Team · 5 November 2026 · 12 min read · Intermediate
In short
Authentication is how a server knows who you are. You sign up once; the server stores only a one-way hash of your password, never the password itself. Each login checks your password against that hash and hands you a signed token. Each request carries the token, and protected routes verify it before answering: no valid token, no data.
- Authentication ≠ authorisation. Authentication answers "who are you?" (login). Authorisation answers "are you allowed?" (admin pages, other users' data). This tutorial is about the first; it sets up the second.
- Passwords are stored as hashes, never as plaintext. A hash is one-way: the server can check a password against it, but nobody can reverse it back into the password.
- A session or token is "proof of login" you carry between requests. HTTP forgets everything between requests, so every request to private data must carry that proof.
- A protected route is a guard, not a wall. It is a small check that runs *before* the route handler: valid proof → continue; missing or forged proof → 401 Unauthorized.
- This tutorial feeds the Node & Express API Foundations course (/course/node-express-api-foundations — module "Authentication and Project Structure": "Register and Login: Hashing Passwords Safely" and "Protecting Routes with Tokens (JWT)") and the Authentication & Sessions (/skill/authentication) skill.
Before you start
What is authentication, exactly?
Every app with user accounts has the same three moments:
- Signup — "I am Ali, here is my password." The server creates the account.
- Login — "It is me again, here is my password." The server checks and says "prove it on future requests with *this*."
- Every request after that — the browser attaches the proof, and the server checks it before touching private data.
The "proof" is either a session (the server remembers you and gives your browser a session ID, usually in a cookie) or a token (a signed string your browser carries; the server verifies the signature instead of remembering anything). The login screen you see is just step 2's user interface — the interesting machinery is steps 1 and 3.
Why can't the server just store my password?
Because databases leak. Backups get copied, old servers get sold, a bug exposes a table — and if passwords sit there in plaintext, every user's password is suddenly public, including the ones they reused on other sites.
So the server stores a hash: the password run through a one-way function. Given the password, the server can recompute the hash and compare; given only the hash, nobody can recover the password. Two extra ingredients make it strong:
- A salt — random data mixed in before hashing, so two users with the same password get different hashes (this defeats precomputed "rainbow table" attacks).
- A slow function — password hashing uses deliberately slow algorithms (scrypt, bcrypt, argon2) so trying billions of guesses takes years, not minutes.
How does a login actually work, step by step?
Follow one user, Ali, through the whole flow:
- Register: Ali sends
ali@example.com+ a password. The server salts and hashes the password, storesemail → salt:hash, and never stores the password itself. - Login attempt: Ali sends email + password again. The server looks up the stored hash, re-hashes the password *he just sent* with the stored salt, and compares. Match → login succeeds.
- Token issued: The server creates a signed token — a small payload (
{ email: "ali@example.com" }) plus a signature made with a secret only the server knows. It hands the token to Ali's browser. - Protected request: Ali asks for
/dashboard. His browser sends the token along. The server recomputes the signature with its secret: if it matches, the token is genuine and untampered with; the server trusts the email inside it and answers. If not — 401 Unauthorized.
The server never needs to "remember" Ali between requests. The token *is* the memory, and the signature is what makes it trustworthy.
Sessions vs tokens — which should I learn first?
Both solve the same problem. The difference is *where the memory lives*:
- Sessions: the server keeps a table of "who is logged in" (session ID → user). The browser carries only the session ID in a cookie. Logging out is easy — delete the row. The cost: every server must share that table, which gets awkward as you add servers.
- Tokens (e.g. JWT): the server keeps nothing; the token carries the user info and a signature. Any server with the secret can verify it. The cost: you cannot easily cancel one token — you wait for it to expire, or keep a revocation list.
Learn the *concept* once (proof of login, verified per request) and both make sense. The demo below uses tokens because the whole flow fits in one file with no database; the Node & Express course lesson "Protecting Routes with Tokens (JWT)" builds the production-shaped version.
What is a protected route?
It is an ordinary route with a guard in front of it. In Express the guard is middleware — a function that runs before the handler:
// the guard: runs before any protected handler
function requireLogin(req, res, next) {
const token = req.headers.authorization; // the browser's proof
const user = verifyToken(token); // null if missing/forged
if (!user) return res.status(401).send("Unauthorized — please log in first");
req.user = user; // the handler now knows who asked
next(); // let the request through
}
app.get("/dashboard", requireLogin, (req, res) => {
res.send(`Welcome back, ${req.user.email}!`);
});That is the whole pattern: check the proof, reject with 401, or attach the user and continue. Every framework has its version; the idea never changes.
Worked example: register, login and a guarded dashboard in Node
This script is the complete flow in one runnable file — no packages to install, only Node's built-in crypto module. Passwords are hashed with scrypt (a real password-hashing function); tokens are signed with HMAC (a hand-built teaching model of what JWT libraries do — see "What production does differently").
const crypto = require("crypto");
// ---- Password hashing with scrypt (a real password-hashing KDF) ----
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(password, stored) {
const [salt, hash] = stored.split(":");
const attempt = crypto.scryptSync(password, salt, 64).toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(attempt, "hex"));
}
// ---- Signed tokens (teaching model of what JWT libraries do) ----
const SECRET = "demo-secret-change-me";
function createToken(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
return `${body}.${sig}`;
}
function verifyToken(token) {
const parts = String(token).split(".");
if (parts.length !== 2) return null;
const [body, sig] = parts;
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
if (sig.length !== expected.length) return null;
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
return JSON.parse(Buffer.from(body, "base64url").toString());
}
// ---- In-memory "database" of users (in a real app: PostgreSQL) ----
const users = [];
function register(email, password) {
if (users.find(u => u.email === email)) return "Error: email already registered";
users.push({ email, passwordHash: hashPassword(password) });
return `Registered ${email}`;
}
function login(email, password) {
const user = users.find(u => u.email === email);
if (!user) return "Error: no account with that email";
if (!verifyPassword(password, user.passwordHash)) return "Error: wrong password";
return createToken({ email });
}
function protectedDashboard(token) {
const claims = verifyToken(token);
if (!claims) return "401 Unauthorized — please log in first";
return `Welcome back, ${claims.email}! Here is your private dashboard.`;
}
// ---- The story, step by step ----
console.log(register("ali@example.com", "correct-horse-123"));
console.log(register("ali@example.com", "another-pass")); // duplicate email
console.log(login("ali@example.com", "wrong-pass")); // wrong password
const token = login("ali@example.com", "correct-horse-123"); // correct login
console.log("token issued:", typeof token === "string");
console.log("plaintext stored?", users[0].passwordHash === "correct-horse-123");
console.log(protectedDashboard(null)); // no token
console.log(protectedDashboard("fake.token")); // forged token
console.log(protectedDashboard(token)); // valid tokenSave it as auth-demo.js, run node auth-demo.js, and you get exactly this (Node.js v24.22.1):
Registered ali@example.com
Error: email already registered
Error: wrong password
token issued: true
plaintext stored? false
401 Unauthorized — please log in first
401 Unauthorized — please log in first
Welcome back, ali@example.com! Here is your private dashboard.Read the output like a security checklist: duplicate registration is refused, a wrong password gets no token, the stored record contains no plaintext password (false), and the dashboard rejects both a missing token and a forged one — only the genuinely signed token gets through.
What production does differently
The demo teaches the concepts honestly, but a real app swaps four things:
- Passwords: use a maintained library (bcrypt, argon2) rather than calling scrypt yourself — the library handles salt generation, cost factors and safe comparison for you. This is exactly the course lesson "Register and Login: Hashing Passwords Safely".
- Tokens: use a tested JWT library instead of hand-rolled HMAC code — the demo's
verifyTokenis 10 lines; the edge cases it skips (algorithm confusion, key rotation) are why libraries exist. This is the course lesson "Protecting Routes with Tokens (JWT)". - Transport: serve everything over HTTPS and store the token in an
HttpOnlycookie where the site's threat model calls for it, so stolen tokens can't be read by injected scripts. - Secrets and storage: the signing secret lives in an environment variable, never in source code; users live in PostgreSQL, not an in-memory array.
Try it yourself
Extend auth-demo.js with the two features every real login system needs: 1. Token expiry. Add exp: Date.now() + 3600_000 (one hour) to the payload in createToken, and make verifyToken return null when claims.exp < Date.now(). Test it with a 1-second expiry: log in, wait two seconds, and confirm the dashboard rejects the token. 2. Logout. Keep a const revoked = new Set(). Add function logout(token) { revoked.add(token); } and make protectedDashboard reject tokens in that set. This is the classic stateless-token trade-off from the Sessions vs tokens section: revocation needs server-side memory. If the expiry test confuses you, log the token payload with JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString()) to see the exp timestamp inside it.
Continue in the free course: Node & Express API Foundations →
Common mistakes
- Storing passwords in plaintext — or with a fast hash. MD5 and SHA-256 are built for speed, which is exactly what helps an attacker guessing passwords. Password storage needs a *slow* function: bcrypt, argon2 or scrypt.
- Hand-rolling crypto in a real app. The demo's token code is a teaching model. Production uses tested libraries — the bugs in home-made token verification are subtle and the payoff for attackers is total.
- Putting tokens where any script can read them without thinking it through.
localStorageis convenient and readable by JavaScript — which means readable by any cross-site-scripting payload too. Understand the trade-off before choosing; many apps preferHttpOnlycookies. - Hardcoding the signing secret in source code. Secrets in code end up in git history, screenshots and shared repos. Environment variables exist for exactly this.
- Guarding most routes but forgetting one. An unprotected
/api/usersnext to a guarded/api/profileis a breach, not a partial win. The verification must run on *every* route that touches private data.
Frequently asked questions
What is the difference between authentication and authorisation?
Authentication proves *who you are* (login with a password). Authorisation decides *what you may do* (admins can delete, regular users cannot). You authenticate first; then each request is authorised based on who the token says you are.
Sessions or tokens — which is better?
Neither, universally. Sessions are easier to revoke (delete the session row and the user is logged out everywhere); tokens scale more easily across many servers because no shared session table is needed. Most beginners meet tokens first via JWT tutorials; what matters is understanding the proof-per-request pattern both share.
How do I log a user out if a token can't be cancelled?
Three common answers: keep token lifetimes short and use refresh tokens; keep a revocation (block) list checked on each request, like the Try It task; or use sessions, where logout is just deleting a row. Every production system picks one deliberately.
Do I need HTTPS?
In production, yes — without it, passwords and tokens travel as readable text that anyone on the network path can capture. For local learning (localhost), plain HTTP is fine because the traffic never leaves your machine.
What does the salt actually do?
It makes identical passwords hash differently. Without a salt, every user with password 123456 shares one hash — crack it once and you have all of them, and precomputed rainbow tables do the rest. The salt is stored openly next to the hash; its job is uniqueness, not secrecy. ---
Follow the Full-Stack Developer Roadmap
Build complete web applications — frontend, backend APIs, databases and deployment. Free, step by step, at your pace.
View the Roadmap