munotes®

Authentication: Passwords, Sessions and Roles

Get access to whole semester resourcesSemester Pass

Chapter Forty-Six

Syllabus topic Module 2, "Application Development: Authentication & validation", the authentication half: registering, signing in, sessions and roles.

Pages 315 to 322 of 499

In one line

Authentication is proving who someone is and remembering it: a password stored only as a slow, salted hash that can never be read back, a sign-in that answers the same way whatever is wrong with it and is limited after five failures, a random token in a cookie the page's scripts cannot read, of which the database keeps only a hash, and a role on every request that decides what that person may do.

In the wording to use when asked: authentication verifies a principal's identity; authorisation decides what that principal may do. Passwords are stored as salted hashes produced by a deliberately slow, memory-hard function, never encrypted or digested with a fast hash; sessions are represented by high-entropy tokens transmitted in HttpOnly, SameSite cookies and stored only as digests; and authorisation is enforced on the server for every request, by role and by ownership.

Registering

FR-1: a name, an email address at the college's own domain, and a password of 8 to 128 characters that is not a common one. The rules live in the validation (Chapter 47), and the domain check in the service (Chapter 43):

  async function register(details) {
    const domain = details.email.split('@')[1];
    if (domain !== config.emailDomain) {
      throw badRequest('Some details need correcting.', {
        email: `Use your @${config.emailDomain} address.`,
      });
    }
    return create(details, 'student');
  }

Registering always makes a student. There is no field in the request that could ask for another role: counter staff are created by the owner, through a different route, and the owner's account is made when the system is set up. A role that a request can choose is a role an attacker can choose.

Storing a password

A password is never stored, and never encrypted either: encryption can be undone, and whoever holds the key can read every password. What is stored is a hash: a value worked out from the password that cannot be turned back into it. A sign-in hashes what was typed and compares.

Two things make that safe:

  • A salt: 16 random bytes, different for every password, mixed in before hashing. Without one, two students who chose the same password would have the same hash, and an attacker could hash a list of common passwords once and look up every match. With one, the work must be done again for every single password.
  • A slow, memory-hard function. A fast hash such as SHA-256 can be tried billions of times a second on a graphics card. OWASP's Password Storage Cheat Sheet asks for something deliberately slow that also needs a lot of memory, which makes those cards far less useful. It ranks Argon2id first and scrypt where Argon2id is not available.

The worked application uses scrypt, and Chapter 36 records why: Node.js added Argon2 to its own crypto module only in version 24.7.0, and NFR-12 supports Node.js 22, where it does not exist. The settings are one of the five combinations the cheat sheet lists as equally strong.

munotes.in315

Authentication: Passwords, Sessions and Roles

'use strict';

const crypto = require('node:crypto');
const { promisify } = require('node:util');

const scrypt = promisify(crypto.scrypt);

// One of the five equal-strength scrypt settings in the
// OWASP Password Storage Cheat Sheet: N = 2^14, r = 8,
// p = 5. It needs 16 MiB, within Node's default limit.
const COST = { N: 16384, r: 8, p: 5 };
const KEY_BYTES = 32;
const SALT_BYTES = 16;

// Returns "scrypt$N$r$p$salt$key", salt and key in base64.
// The settings travel with the hash, so they can be raised
// later without breaking the passwords already stored.
async function hashPassword(password) {
  const salt = crypto.randomBytes(SALT_BYTES);
  const key = await scrypt(password, salt, KEY_BYTES, COST);
  return [
    'scrypt', COST.N, COST.r, COST.p,
    salt.toString('base64'), key.toString('base64'),
  ].join('$');
}

async function verifyPassword(password, stored) {
  const parts = String(stored).split('$');
  if (parts.length !== 6 || parts[0] !== 'scrypt') {
    return false;
  }
  const [, N, r, p, saltText, keyText] = parts;
  const salt = Buffer.from(saltText, 'base64');
  const expected = Buffer.from(keyText, 'base64');
  const cost = { N: Number(N), r: Number(r), p: Number(p) };
  const key = await scrypt(password, salt, expected.length, cost);
  // Compares every byte whatever happens, so the time taken
  // does not tell an attacker how much of a guess was right.
  return crypto.timingSafeEqual(key, expected);
}

module.exports = { hashPassword, verifyPassword };
  • The settings travel with the hash: scrypt$16384$8$5$salt$key. Raising the cost later, or moving to Argon2id, is then possible without resetting anybody's password: each hash says how it was made, and a sign-in can re-hash with the new settings once the old one has verified.
  • timingSafeEqual compares every byte whatever happens. An ordinary comparison stops at the first difference, and the time it takes can tell an attacker how much of a guess was right.
  • Nothing here logs anything. A password must never reach a log file (S3).

What it produces, and that the salt really does its work:

$ cd ~/canteen-preorder
$ node -e "
> const { hashPassword, verifyPassword } = require('./src/passwords');
> (async () => {
>   const a = await hashPassword('the same password');
>   const b = await hashPassword('the same password');
>   const part = (h) => h.split('\$');
>   console.log('settings:', part(a).slice(0, 4).join('\$'));
>   console.log('salt and key, in bytes:',
>     Buffer.from(part(a)[4], 'base64').length,
>     Buffer.from(part(a)[5], 'base64').length);
>   console.log('two hashes of one password are equal?', a === b);
>   console.log('right password verifies?',
>     await verifyPassword('the same password', a));
>   console.log('wrong password verifies?',
>     await verifyPassword('the same password ', a));
>   const t = Date.now();
>   await hashPassword('timing');
>   console.log('one hash takes about', Date.now() - t, 'ms');
> })();
> "
settings: scrypt$16384$8$5
salt and key, in bytes: 16 32
two hashes of one password are equal? false
right password verifies? true
wrong password verifies? false
one hash takes about 163 ms
munotes.in316

Authentication: Passwords, Sessions and Roles

The two hashes of one password are different, because the salts are. And a hash takes a noticeable fraction of a second on purpose: an attacker who steals the table must spend that on every guess, while a student signing in spends it once.

Which passwords are refused

ASVS 5.0 level 1 asks for three things, and the application does all three (Chapter 31):

ASVSWhat it asksHere
6.2.1at least 8 characters, 15 strongly recommended8 to 128, with the gap from NIST's 15 recorded as accepted
6.2.4refuse at least the 3,000 most common passwords that fit the policythe 10,000 most common of 8 to 128 characters
6.2.5no composition rulesnone: any characters at all

No composition rules is worth dwelling on, because it looks like laxity and is the opposite. Demanding a capital, a digit and a symbol makes passwords harder to remember and barely harder to guess: people answer it with Password1!, which is on every attacker's list. Refusing the common ones attacks the same problem where it actually is.

The check itself is four lines of the validation, with the list read once at start-up:

// The 10,000 most common passwords of 8 to 128 characters,
// read once at start-up (data/README.md says where they come
// from). A password on this list is among the first an
// attacker tries, so it is refused whatever its length.
const COMMON = new Set(fs.readFileSync(
  path.join(__dirname, '..', 'data', 'common-passwords.txt'),
  'utf8').split('\n').filter(Boolean));
function checkPassword(p, password) {
  if (typeof password !== 'string'
      || password.length < 8 || password.length > 128) {
    p.add('password', 'Use a password of 8 to 128 characters.');
  } else if (COMMON.has(password)
      || COMMON.has(password.toLowerCase())) {
    p.add('password', 'This password is too common. '
      + 'Choose one that is hard to guess.');
  }
}

A Set answers in the same time whether it holds 10 or 10,000 words, so the check costs nothing; and the lower-cased form is checked too, so PassWord1 is refused along with password1.

Where the list comes from matters as much as having one. A list of passwords downloaded from anywhere is not evidence; this one is named, licensed and reproducible:

# data/common-passwords.txt

The 10,000 most commonly used passwords that are 8 to 128 characters long,
the lengths the application would otherwise accept, one to a line, the most
common first. Registration and new staff accounts refuse every one of them
(SRS FR-1, security control S5). OWASP's Application Security Verification
Standard 5.0, requirement 6.2.4, asks for at least the 3,000 most common.

## Where it comes from

SecLists, the collection of security testing lists by Daniel Miessler and its
contributors, file
`Passwords/Common-Credentials/xato-net-10-million-passwords-100000.txt`:
100,000 different passwords from Mark Burnett's ten million passwords
dataset of 2015, sorted from most to least common. Downloaded on
30 September 2026, SHA-256
`1472aafa2561df5e3293aee252aee3ca660c12b399a283cf808bb01b39be388b`.

Made with:

    curl -fsSLo xato.txt https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/xato-net-10-million-passwords-100000.txt
    tr -d '\r' < xato.txt | awk 'length >= 8 && length <= 128' | head -n 10000 > data/common-passwords.txt

## Licence

SecLists is released under the MIT License:

    MIT License

    Copyright (c) 2018 Daniel Miessler

    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files (the "Software"), to deal
    in the Software without restriction, including without limitation the rights
    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    copies of the Software, and to permit persons to whom the Software is
    furnished to do so, subject to the following conditions:

    The above copyright notice and this permission notice shall be included in all
    copies or substantial portions of the Software.

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    SOFTWARE.
munotes.in317

Authentication: Passwords, Sessions and Roles

The registration page asks for a password used nowhere else, in the label itself so that a screen reader reads it with the field (Chapter 40):

      <label for="register-password">Password: 8 or more
        characters, and not one you use anywhere else</label>

And the refusal, seen from outside:

$ cd ~/canteen-preorder
$ npm start > server.log 2>&1 &
$ curl -s -o /dev/null --retry 10 --retry-connrefused localhost:3000/api/health
$ curl -s -H 'Content-Type: application/json' \
>   -d '{"name":"Meera Joshi","email":"meera@college.example","password":"sunshine1"}' \
>   localhost:3000/api/auth/register | jq -c .error.details
{"password":"This password is too common. Choose one that is hard to guess."}
$ curl -s -H 'Content-Type: application/json' \
>   -d '{"name":"Meera Joshi","email":"meera@college.example","password":"peas-under-the-bench"}' \
>   localhost:3000/api/auth/register | jq -c .user
{"id":7,"name":"Meera Joshi","email":"meera@college.example","role":"student"}
$ wc -l < data/common-passwords.txt
10000

Signing in

login in the auth service (Chapter 43) does five things in order, and each is a control:

  1. The limit first. The key is the address the request came from and the email tried, and five failures in fifteen minutes gives 429 with the seconds to wait (NFR-6, S4). Keying on both means one noisy network cannot lock out every account, and one attacker cannot try a thousand passwords for one student.
  2. Find the user, or, if there is none, use a dummy hash prepared at start-up, so that the sign-in takes the same time either way (S16).
  3. Verify, in constant time.
  4. One answer for every failure: a wrong password, an unknown email and a switched-off account all give 401 wrong_credentials with the same sentence. An attacker learns nothing about who has an account.
  5. Make the session: 32 random bytes, and only its SHA-256 in the database (S6, ADR-3).
munotes.in318

Authentication: Passwords, Sessions and Roles

The limiter of step 1 is a file of its own, with no database and no clock of its own, which is what lets the tests wind it forward without waiting fifteen minutes:

'use strict';

// Counts failed sign-ins per key (the address the request
// came from, plus the email tried) and refuses a key that has
// failed `max` times inside `windowMs`. The clock is passed
// in so that a test can move time forward without waiting.
//
// It is kept in memory, so it forgets everything when the
// server restarts and it would not be shared between two
// servers. For one canteen server that is enough; the SRS
// records the limit.
function attemptLimiter({ max, windowMs, clock = Date.now }) {
  const failures = new Map();

  function recent(key) {
    const since = clock() - windowMs;
    const times = (failures.get(key) || []).filter((t) => t > since);
    if (times.length > 0) failures.set(key, times);
    else failures.delete(key);
    return times;
  }

  return {
    // Seconds until this key may try again; 0 if it may now.
    blockedFor(key) {
      const times = recent(key);
      if (times.length < max) return 0;
      return Math.ceil((times[0] + windowMs - clock()) / 1000);
    },
    fail(key) {
      failures.set(key, [...recent(key), clock()]);
    },
    reset(key) {
      failures.delete(key);
    },
  };
}

module.exports = { attemptLimiter };

It keeps its counts in memory, which is the honest choice for one server and a recorded limitation for more than one: a second copy of the application would count its own failures and not the first's. Restarting the server forgives everybody, which is a cost worth naming in the report.

  async function login({ email, password }, ip) {
    const key = `${ip}|${email}`;
    const wait = attempts.blockedFor(key);
    if (wait > 0) throw tooManyAttempts(wait);
    const user = await store.users.findByEmail(email);
    const hash = user ? user.passwordHash : await noSuchUser;
    const ok = await verifyPassword(password, hash);
    if (!user || !ok || !user.isActive) {
      attempts.fail(key);
      throw new AppError(401, 'wrong_credentials',
        'The email or the password is wrong.');
    }
    attempts.reset(key);
    const token = crypto.randomBytes(32).toString('base64url');

That the times really are alike can be measured:

$ cd ~/canteen-preorder
$ node -e "
> const run = async (email) => {
>   const t = Date.now();
>   await fetch('http://localhost:3000/api/auth/login', {
>     method: 'POST', headers: { 'Content-Type': 'application/json' },
>     body: JSON.stringify({ email, password: 'not the password' }),
>   });
>   return Date.now() - t;
> };
> (async () => {
>   await run('warm@college.example');
>   console.log('a real student, wrong password:',
>     await run('kabir@college.example'), 'ms');
>   console.log('nobody with that address:   ',
>     await run('nobody@college.example'), 'ms');
> })();
> "
a real student, wrong password: 158 ms
nobody with that address:    145 ms
munotes.in319

Authentication: Passwords, Sessions and Roles

Both take about as long as one scrypt hash, because both do one. Without the dummy hash the second would answer in a millisecond, and an attacker could sort the college's addresses into those that have accounts and those that do not, simply by timing.

The cookie, and the session

function cookieOptions(config) {
  return {
    httpOnly: true, // page scripts cannot read the cookie
    sameSite: 'lax', // not sent on another site's form posts
    secure: config.cookieSecure, // HTTPS only, once it is on
    maxAge: config.sessionHours * 60 * 60 * 1000,
    path: '/',
  };
}

Each flag answers a threat (NFR-4):

  • HttpOnly: no script can read the cookie, so even a script injected into the page could not steal the sign-in.
  • SameSite=Lax: the browser does not send it with a form posted from another site, which is one of three defences against cross-site request forgery (Chapter 31, S7).
  • Secure, whenever the site uses HTTPS: the cookie is then never sent over plain HTTP. On the college Wi-Fi trial it is off, because there is no HTTPS, and that is the trial's recorded weakness (Chapter 25).
  • Eight hours, and the row is deleted at sign-out, so a cookie copied from a shared laptop stops working.

On every request one middleware turns the cookie into a person:

'use strict';

// Reads the Cookie header into { name: value }.
function parseCookies(header) {
  const cookies = {};
  for (const part of (header || '').split(';')) {
    const at = part.indexOf('=');
    if (at < 1) continue;
    const name = part.slice(0, at).trim();
    const value = part.slice(at + 1).trim();
    try {
      cookies[name] = decodeURIComponent(value);
    } catch {
      cookies[name] = value;
    }
  }
  return cookies;
}

// Looks up who is signed in, from the "sid" cookie, and puts
// that person on req.user (or null) for the routes to read.
function loadSession(auth) {
  return async (req, res, next) => {
    const token = parseCookies(req.get('Cookie')).sid;
    req.sessionToken = token;
    req.user = token ? await auth.userFromToken(token) : null;
    next();
  };
}

module.exports = { parseCookies, loadSession };

It never refuses anything: req.user is either a user or null, and the guards decide. A session whose row has expired or whose user has been switched off simply returns null, because the query that looks it up says so (Chapter 44).

munotes.in320

Authentication: Passwords, Sessions and Roles

Roles: what each person may do

The role is on the user, and two guards use it (Chapter 42):

'use strict';

const { notSignedIn, forbidden } = require('../errors');

// Placed in front of a route: only signed-in users get past.
function requireUser(req, res, next) {
  next(req.user ? undefined : notSignedIn());
}

// Only signed-in users with one of these roles get past.
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) return next(notSignedIn());
    if (!roles.includes(req.user.role)) return next(forbidden());
    next();
  };
}

module.exports = { requireUser, requireRole };

Role is not enough. Being a student does not entitle anyone to this order: the service checks the owner of the row as well, and answers "not found" for someone else's (S1). Role answers "may this kind of person do this kind of thing"; ownership answers "is this their thing".

The whole ladder, from outside:

$ cd ~/canteen-preorder
$ curl -s -o /dev/null -w "nobody signed in:      %{http_code}\n" \
>   localhost:3000/api/reports/daily
nobody signed in:      401
$ curl -s -c priya -H 'Content-Type: application/json' -o /dev/null \
>   -d '{"email":"priya@college.example","password":"canteen-demo"}' \
>   localhost:3000/api/auth/login
$ curl -s -b priya -o /dev/null -w "a student:             %{http_code}\n" \
>   localhost:3000/api/reports/daily
a student:             403
$ curl -s -c lata -H 'Content-Type: application/json' -o /dev/null \
>   -d '{"email":"owner@college.example","password":"canteen-demo"}' \
>   localhost:3000/api/auth/login
$ curl -s -b lata -o /dev/null -w "the owner:             %{http_code}\n" \
>   localhost:3000/api/reports/daily
the owner:             200
$ curl -s -b priya localhost:3000/api/auth/me | jq -c .user
{"id":3,"name":"Priya Menon","email":"priya@college.example","role":"student"}

401 with nobody signed in, 403 for the wrong role, 200 for the owner: the API table of Chapter 30, enforced. And me never carries the password hash, because the service returns only what publicUser builds.

Do this for your project

  1. Store passwords only as salted hashes from a slow, memory-hard function, and keep the settings with each hash.
  2. Compare hashes in constant time, and never log a password or a cookie.
  3. Refuse the common passwords, from a named and licensed list, and set no composition rules.
  4. Give every failed sign-in the same answer, and do the same work whether the account exists or not.
  5. Limit failed sign-ins by address and account together.
  6. Use a long random session token, store only its hash, and set HttpOnly, SameSite and Secure with an expiry.
  7. Decide a role on the server for every request, and check ownership as well as role.

Mistakes that cost marks

Passwords stored as they were typed, or encrypted, or hashed with SHA-256 or MD5, with no salt.

"Wrong password" and "no such user" as different messages, which hands an attacker a list of accounts.

munotes.in321

Authentication: Passwords, Sessions and Roles

A role sent by the browser, in the request or in a cookie the page can write.

A session token that is the user's id, or a number that counts up.

No limit on sign-in attempts, so a script can try a million passwords overnight.

Composition rules that produce Password1! and a sticky note on the counter.

Quick revision

  • Hash, never store or encrypt: scrypt (OWASP settings), a 16-byte salt each, settings inside the hash, timingSafeEqual.
  • ASVS 5.0: 6.2.1 8 minimum (15 recommended), 6.2.4 refuse the common ones, 6.2.5 no composition rules.
  • Sign-in: limit first (address and email, 5 in 15 minutes), dummy hash for unknown emails, one message for every failure.
  • Session: 32 random bytes, only the SHA-256 stored; cookie HttpOnly, SameSite=Lax, Secure under HTTPS, 8 hours.
  • Guards: 401 with nobody signed in, 403 for the wrong role; ownership is checked separately.

Questions you must be able to answer

1. Why is a password hashed rather than encrypted? Because encryption can be undone by whoever holds the key, so a stolen database and a stolen key give up every password. A hash cannot be turned back at all: the system only ever hashes what was typed and compares.

2. What does the salt do? It makes every stored hash different, even for two people who chose the same password, so an attacker cannot hash a list of likely passwords once and match it against the whole table, and must attack each password separately.

3. Why use a deliberately slow function such as scrypt? Because the attacker's work is millions of guesses and the user's is one. A fast hash can be tried billions of times a second on a graphics card; a slow, memory-hard one reduces that to a rate at which guessing a good password is hopeless, while a sign-in still takes a fraction of a second.

4. Why does the sign-in check a dummy hash when the email is unknown? So that the request takes the same time whether the account exists or not. Otherwise an attacker could tell which addresses have accounts by timing the answers, even though the message is the same.

5. Why does the database store only a hash of the session token? Because the token in the cookie is what proves who you are. If the sessions table were copied, stored tokens could be used to sign in as anybody; a hash cannot be turned back into the token, so the copy is useless.

6. Is hiding the Owner link on the counter's page a security measure? No. It is a convenience, so that staff do not see a page that is not theirs. The owner's routes check the role on the server for every request, and would refuse anyone else whatever the page showed.

munotes.in322

The rest of this subject

These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.

Issue
Done!