The Escena Viva User model has been sitting there since lesson 07-02 with its email, its name and its role — attendee, organizer, administrator — and no password field at all. That was not an oversight: it was a reserved gap. In this lesson we fill it with the rigor it deserves, because password storage is the one place where a mistake goes unnoticed until it becomes a newspaper headline.

We will see why a fast hash is useless, what bcrypt actually does, how to choose its cost factor by measuring, and how to write POST /auth/register and POST /auth/login without leaking which e-mails exist or how long each branch of the code takes. We will finish with what is most often done badly: verification and recovery tokens.

Contents

  1. Why passwords are never stored in the clear
  2. What a password hash is
  3. bcrypt, scrypt and Argon2
  4. bcrypt in practice
  5. The native alternative: crypto.scrypt
  6. Completing the User model
  7. Password policy and the zod schema
  8. POST /auth/register and user enumeration
  9. POST /auth/login and constant time
  10. Attempt limiting and logging
  11. E-mail verification and recovery
  12. Common mistakes, exercises and conclusion

  1. Why passwords are never stored in the clear

Imagine the escena_viva database leaks: a badly permissioned backup, a SQL injection, a disgruntled employee. If there is a password column holding the raw text, three things happen: every account is compromised, including those of organizers and administrators; accounts on other services are compromised too, because people reuse passwords (Lucía's e-mail and her password get tried automatically on dozens of sites: that is credential stuffing); and the passwords remain valid until each person changes them one by one.

Encrypt them with a key? No good either. Encryption is reversible by design: there is a key that returns the original, that key lives on the server, and whoever steals the database can usually steal the server too. Besides, there is no legitimate reason to recover a password: the server only needs to check whether the one just supplied is the right one. What about a hash like MD5 or SHA-256? It is irreversible, yes, but far too fast:

Algorithm Attempts/second (GPU) Dictionary of 10^10 candidates
MD5 / SHA-256 ~10^11 / ~10^10 seconds / minutes
bcrypt cost 12 ~10^3 thousands of years

Speed is a virtue for verifying files and a disaster for passwords: what makes the server fast makes the attacker fast, and the attacker has GPUs. Add that an unsalted hash of a common password is precomputed in public rainbow tables (5f4dcc3b5aa765d61d8327deb882cf99 is password in any search engine), and that two users with the same password would produce identical hashes, which is a leak in itself.

  1. What a password hash is

A password hash (or key derivation function) is designed with three deliberate properties:

  1. It is slow on purpose: on the order of 100–500 ms. The legitimate user does not care; the attacker trying millions of combinations is ruined by it.
  2. It uses a unique salt per user: a random string stored alongside the hash. It defeats rainbow tables and makes identical passwords produce different hashes. The salt is not secret: its job is uniqueness.
  3. It has an adjustable cost factor: hardware improves every year, the parameter is raised, and the algorithm keeps serving for a decade.
  4. The best ones add memory cost, so that throwing lots of GPU cores at it in parallel is not enough.

  1. bcrypt, scrypt and Argon2

Algorithm CPU cost Memory cost Parameters Notes
bcrypt (1999) Yes Low and fixed (4 KB) logarithmic cost Very mature. 72-byte limit on the input
scrypt (2009) Yes Yes, adjustable N, r, p In node:crypto, no dependencies
Argon2id (2015) Yes Yes, adjustable time, memory, parallelism Winner of the Password Hashing Competition; the best option today

A reasoned recommendation: if you are starting today and can add the argon2 dependency, use Argon2id, which resists specialized hardware better thanks to its memory cost; if you need zero native dependencies, crypto.scrypt already ships with Node and is perfectly defensible. In Escena Viva we use bcrypt because it is what you will find in the vast majority of existing code, its API explains itself without noise, and its security is adequate with a well-chosen cost. What matters is the model; switching algorithms will be a change confined to a single module, and that is how we will write it. Never acceptable: MD5, SHA-1, "salted SHA-256" or any homemade invention based on concatenating and rehashing.

  1. bcrypt in practice

npm install bcrypt

bcrypt compiles a native module; if that complicates your deployment (minimal containers, serverless functions), bcryptjs is the pure JavaScript implementation, API-compatible and about three times slower.

// src/services/passwords.js
'use strict';
const bcrypt = require('bcrypt');
const { configuration } = require('../config/index.js');
const { ValidationError } = require('../errors.js');
// bcrypt's usable limit: 72 bytes. We reject before hashing so we never
// truncate silently (two long passwords sharing their first 72 bytes
// would be considered identical).
const MAX_BYTES = 72;
async function hashPassword(plainPassword) {
  if (Buffer.byteLength(plainPassword, 'utf8') > MAX_BYTES) {
    throw new ValidationError('The password exceeds the maximum allowed length');
  }
  // The cost lives in configuration: it is raised without touching this file.
  return bcrypt.hash(plainPassword, configuration.bcryptCost);
}
async function verifyPassword(plainPassword, storedHash) {
  if (!storedHash) { return false; }
  // compare extracts the salt and the cost from the hash itself, and
  // compares in constant time: it does not stop at the first differing byte.
  return bcrypt.compare(plainPassword, storedHash);
}
module.exports = { hashPassword, verifyPassword, MAX_BYTES };

A bcrypt hash explains itself:

$2b$12$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └──────── salt (22 chars) ─────┘└──── hash (31 chars) ───┘
 │   └── cost factor: 12              └── algorithm variant

That is why bcrypt.compare does not need you to pass the salt or the cost: they are inside the hash. And that is why raising the cost does not invalidate old hashes: each one is verified with its own.

The cost factor is logarithmic: 12 means 2^12 = 4096 iterations and each point doubles the time. Rule of thumb: the largest value that keeps login below roughly 250 ms on your production hardware. Measure it, do not copy it:

// scripts/measure-bcrypt.js
'use strict';
const bcrypt = require('bcrypt');
(async () => {
  for (let cost = 10; cost <= 15; cost += 1) {
    const start = process.hrtime.bigint();
    await bcrypt.hash('escena-viva-test-password', cost);
    console.log(`cost ${cost}: ${(Number(process.hrtime.bigint() - start) / 1e6).toFixed(0)} ms`);
  }
})();
// node scripts/measure-bcrypt.js
// cost 10: 62 ms | cost 11: 124 ms | cost 12: 248 ms (chosen) | cost 13: 495 ms

Why constant-time comparison matters. In Module 3, with Buffers, we met crypto.timingSafeEqual: an ordinary comparison (===) stops at the first differing byte, so the elapsed time reveals how many bytes matched, and by repeating the measurement thousands of times a secret is reconstructed byte by byte. bcrypt.compare always compares the full 60 characters; the same logic will apply to the tokens in section 11.

  1. The native alternative: crypto.scrypt

// src/services/passwords-scrypt.js  (dependency-free alternative)
'use strict';
const { randomBytes, scrypt, timingSafeEqual } = require('node:crypto');
const scryptAsync = require('node:util').promisify(scrypt);
// N=2^15 demands about 32 MB per hash: that makes a GPU attack expensive,
// since GPUs have plenty of compute and little memory.
const P = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
async function hashPassword(plainPassword) {
  const salt = randomBytes(16);
  const derived = await scryptAsync(plainPassword, salt, 64, P);
  // We store parameters + salt + hash: the format must describe itself,
  // so old hashes keep verifying if we raise N tomorrow.
  return `scrypt$${P.N}$${P.r}$${P.p}$${salt.toString('base64')}$${derived.toString('base64')}`;
}
async function verifyPassword(plainPassword, storedHash) {
  const [label, n, r, p, saltB64, hashB64] = String(storedHash).split('$');
  if (label !== 'scrypt') { return false; }
  const expected = Buffer.from(hashB64, 'base64');
  const derived = await scryptAsync(plainPassword, Buffer.from(saltB64, 'base64'),
    expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: P.maxmem });
  return timingSafeEqual(derived, expected); // mandatory: we compare by hand (M3)
}
module.exports = { hashPassword, verifyPassword };

Notice the detail almost nobody includes: storing the parameters next to the hash, which is exactly what bcrypt does for you. Without them, raising N would invalidate every previous hash.

  1. Completing the User model

// src/models/user.js
'use strict';
const mongoose = require('mongoose');
const ROLES = Object.freeze(['attendee', 'organizer', 'administrator']);
const userSchema = new mongoose.Schema({
  // Always normalize: '[email protected]' and '[email protected]'
  // must be the SAME account, or the unique index is worthless.
  email: { type: String, required: true, unique: true, lowercase: true, trim: true, index: true },
  name: { type: String, required: true, trim: true, maxlength: 120 },
  // select: false => NEVER comes back from a query unless asked for with
  // .select('+passwordHash'): it shields us from "we returned the whole user".
  passwordHash: { type: String, required: true, select: false },
  role: { type: String, enum: ROLES, default: 'attendee', required: true },
  verified: { type: Boolean, default: false },
  assignedVenue: { type: String, default: null }, // organizers only
  createdAt: { type: Date, default: () => new Date() },
});
// Belt and braces: even if somebody asks for +passwordHash, it disappears on serialization.
userSchema.set('toJSON', {
  transform: (doc, plain) => { delete plain.passwordHash; delete plain.__v; return plain; },
});
const User = mongoose.model('User', userSchema);
module.exports = { User, ROLES };

select: false turns "do not leak the hash" into the default behavior instead of a discipline you have to remember; the only place that will ask for it explicitly is the login. lowercase: true avoids the subtle bug of ending up with two accounts for the same e-mail: technically the local part is case-sensitive, but no real provider exploits that, and normalizing is what users expect.

  1. Password policy and the zod schema

For twenty years the rule was "8 characters with an uppercase letter, a digit and a symbol". NIST changed its mind (SP 800-63B) because the data showed those rules produce Summer2024! everywhere: short, predictable passwords that are hard to remember and end up written on a sticky note.

Modern rule Discarded old rule
Minimum 12 characters, generous maximum; spaces and Unicode allowed (passphrases) Minimum 8 with composition rules; ban "odd" characters
Check against lists of breached passwords Mandatory expiry every 90 days
Forced change only on suspicion Ban reusing the last 5
// src/schemas/authentication.js
'use strict';
const { z } = require('zod');
const { MAX_BYTES } = require('../services/passwords.js');
const emailSchema = z.string().trim().toLowerCase().email().max(254);
// The maximum protects against two things: bcrypt's 72-byte limit and
// denial of service by hashing enormous inputs.
const passwordSchema = z.string().min(12, 'At least 12 characters').max(MAX_BYTES);
// .strict() rejects extra fields: nobody assigns themselves a role.
const registerSchema = z.object({ email: emailSchema, password: passwordSchema,
  name: z.string().trim().min(2).max(120) }).strict();
// At login the password is validated with min(1), NOT with the policy:
// an old user with 9 characters would be locked out of their own account.
const loginSchema = z.object({ email: emailSchema,
  password: z.string().min(1).max(MAX_BYTES) }).strict();
module.exports = { registerSchema, loginSchema, emailSchema, passwordSchema };

Without .strict(), a body containing "role":"administrator" could end up creating an administrator if somebody writes new User(req.body): that is mass assignment, and we come back to it in 08-06. The check against breached-password lists (Have I Been Pwned) is done with k-anonymity — you send the first 5 characters of the SHA-1 and filter locally, never revealing the password — and although we do not implement it here, in production it has one of the best cost/benefit ratios around.

  1. POST /auth/register and user enumeration

// src/controllers/authentication.js  (excerpt: registration)
'use strict';
const { User } = require('../models/user.js');
const { hashPassword } = require('../services/passwords.js');
async function register(req, res, next) {
  // req.validatedData is left by the validate() middleware from module 6.
  const { email, password, name } = req.validatedData.body;
  if (!await User.exists({ email })) {
    const passwordHash = await hashPassword(password);
    // Explicit fields, NEVER ...req.body: the server sets the role.
    await User.create({ email, name, passwordHash, role: 'attendee' });
    // The verification e-mail would be sent here (section 11).
  }
  // Same response whether or not the account exists: we do not leak which e-mails are registered.
  res.status(202).json({ message: 'If the address is valid, you will receive a message to activate the account' });
}

Go over what it does not do: it does not return the created user, it does not return the hash, it does not say whether the e-mail already existed, and it does not accept the whole body. Four deliberate omissions. The routes, in src/routes/authentication.js → { createAuthRoutes }, chain the rate limit and module 6's validate(schema, VALID_SOURCES.BODY) before each controller.

Answering "that e-mail is already registered" looks friendly and is an information leak: it lets an attacker confirm which addresses have accounts before launching credential stuffing, and in sensitive services it reveals membership. It leaks through four channels and all four have to be closed:

Channel Leak and fix
Error message "E-mail already registered" → always an identical message
HTTP code 409 if it exists, 201 if not → always the same code (202)
Response time Fast when it does not exist → always hash, even in vain
Recovery "No account with that address" → "if it exists, we have sent you a message"

The honest trade-off: this hurts usability, and somebody who signed up a year ago gets no clear warning. The correct solution is to let the e-mail clear it up: if the account already exists, send a message saying "somebody tried to register with your address; if that was you, here is the link to recover it". An attacker who does not control the mailbox sees nothing; the legitimate owner does. Deciding is a judgment call: on a recipe forum, a clear 409 is defensible; on a platform holding purchase data, it is not.

  1. POST /auth/login and constant time

// src/controllers/authentication.js  (excerpt: login)
// Decoy hash: a valid bcrypt hash of a password nobody knows.
const DECOY_HASH = '$2b$12$C6UzMDM.H6dfI/f/IKcEeO6iVQ9Lm2ZaZ8Xk1lF4a2iSg9WbLh9nK';
async function login(req, res, next) {
  const { email, password } = req.validatedData.body;
  // +passwordHash: the only place in the codebase that asks for it.
  const user = await User.findOne({ email }).select('+passwordHash');
  // We ALWAYS verify, whether or not the user exists. Without the decoy, a
  // "nonexistent user" would answer in 2 ms and a "bad password" in 250 ms:
  // timing the response would reveal which addresses have an account.
  const matches = await verifyPassword(password, user ? user.passwordHash : DECOY_HASH);
  if (!user || !matches) { // IDENTICAL message and code in both cases
    return next(new AuthenticationError('Invalid credentials'));
  }
  if (!user.verified) {
    return next(new AuthenticationError('The account is not verified yet'));
  }
  // From here on, issue the session (08-03) or the tokens (08-04).
  req.authenticatedUser = { id: String(user._id), name: user.name, role: user.role };
  next();
}

We add the errors to src/errors.js, following the module 6 pattern:

class AuthenticationError extends ApplicationError {
  constructor(message = 'Invalid credentials', details) {
    super(message, { appCode: 'NOT_AUTHENTICATED', isOperational: true, details });
  }
}
class AuthorizationError extends ApplicationError {
  constructor(message = 'You do not have permission for this operation', details) {
    super(message, { appCode: 'NO_PERMISSION', isOperational: true, details });
  }
}

We extend the STATUS_BY_CODE table in src/middleware/errors.js with NOT_AUTHENTICATED: 401, NO_PERMISSION: 403 and TOO_MANY_REQUESTS: 429. That way a failed login returns the usual format:

{ "error": { "code": "NOT_AUTHENTICATED", "message": "Invalid credentials", "status": 401 } }

  1. Attempt limiting and logging

Slow hashing makes the attack expensive per password tried; rate limiting makes it expensive per attempt. You need both:

// src/middleware/limits.js  (additions)
const loginLimit = rateLimit({
  windowMs: 15 * 60 * 1000, limit: 10,
  standardHeaders: 'draft-7', legacyHeaders: false, // includes Retry-After
  // Key by IP + e-mail: this also slows the distributed attack that spreads
  // attempts against ONE account across many IP addresses.
  keyGenerator: (req) => `${req.ip}:${String(req.body?.email || '').toLowerCase()}`,
});
const registerLimit = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5 });
module.exports = { generalLimit, purchaseLimit, loginLimit, registerLimit };

Beware of account lockout: if you lock an account after N failures, you have created a denial of service against any user whose address is known; an increasing delay, a CAPTCHA past a threshold and an e-mail warning are preferable. In 08-06 we tune the limits per route type and explain why an in-memory store is no good with several processes.

What gets logged: timestamp, requestId (M6), e-mail or user id, outcome and a generic reason, IP address and user agent. What never does: the password — not even hashed — the stored hash, session, access or recovery tokens, and the full request body. The most common real-world mistake is not writing console.log(password), it is dumping the entire request in a debugging middleware or an error reporter; that is why a centralized list of redacted fields pays off: password, currentPassword, token, authorization, cookie.

  1. E-mail verification and recovery

Both flows use the same primitive: a single-use token sent by e-mail. Four rules, no exceptions:

  1. Cryptographically random, not Math.random() and not a version 1 UUID: randomBytes(32).
  2. Stored hashed (SHA-256 is enough: the token already has 256 bits of entropy, no slowness required), so whoever steals the database cannot use the pending tokens.
  3. Expires soon: 1 hour for recovery, 24 h for verification.
  4. Single use: deleted when consumed. And on use, existing sessions and refresh tokens are invalidated: if somebody had signed in with the stolen password, the change must throw them out.
// src/services/email-tokens.js
'use strict';
const { randomBytes, createHash, timingSafeEqual } = require('node:crypto');
const { EmailToken } = require('../models/email-token.js');
const EXPIRY = Object.freeze({ verification: 24 * 60 * 60 * 1000, recovery: 60 * 60 * 1000 });
// SHA-256 is correct HERE (and not for passwords) because the token has
// 256 bits of entropy: no dictionary can ever reach it.
const hashToken = (token) => createHash('sha256').update(token).digest('hex');
async function issueToken(userId, type) {
  // 32 bytes = 256 bits. base64url so it travels cleanly inside a URL (M3).
  const token = randomBytes(32).toString('base64url');
  await EmailToken.deleteMany({ userId, type }); // invalidates the previous ones
  await EmailToken.create({ userId, type, tokenHash: hashToken(token),
    expiresAt: new Date(Date.now() + EXPIRY[type]) });
  return token; // in the clear ONCE, to build the link in the e-mail
}
async function consumeToken(token, type) {
  const hash = hashToken(token);
  const record = await EmailToken.findOne({ tokenHash: hash, type });
  if (!record || record.expiresAt.getTime() < Date.now()) { return null; }
  // Constant-time comparison over the hash (M3).
  if (!timingSafeEqual(Buffer.from(record.tokenHash), Buffer.from(hash))) { return null; }
  await record.deleteOne(); // single use: it is consumed here
  return String(record.userId);
}
module.exports = { issueToken, consumeToken, EXPIRY };

The EmailToken model stores userId, type (verification or recovery), tokenHash (unique) and expiresAt with an { expireAfterSeconds: 0 } index, so that MongoDB deletes expired documents on its own with no cleanup job needed.

Common mistake How we avoid it
Token = an encoded id or e-mail: guessable, and any account is taken over 256 random bits
Token stored in the clear: leaking the database = taking over any account The hash is what gets stored
No expiry: an old e-mail still works years later expiresAt + TTL index
Reusable: the link in the browser history works again Deleted on consumption
Not closing sessions when the password changes: the attacker stays in Revoke sessions and refresh tokens

And changing the password while signed in, which always requires the current one:

async function changePassword(req, res, next) {
  const { currentPassword, newPassword } = req.validatedData.body;
  const user = await User.findById(req.user.id).select('+passwordHash');
  // Requiring the current password blocks a takeover from a session left
  // open and forgotten on a shared computer.
  if (!await verifyPassword(currentPassword, user.passwordHash)) {
    return next(new AuthenticationError('Invalid credentials'));
  }
  user.passwordHash = await hashPassword(newPassword);
  await user.save();
  await revokeAllRefreshTokens(user._id); // only the current session survives
  res.status(204).end();
}

Common Mistakes and Tips

  • Hashing on the client and sending the hash. It does not help: the hash becomes the password, and whoever steals the database authenticates with it directly. The password travels in the clear inside TLS and is hashed on the server.
  • A single shared global salt. The salt is unique per user. An extra application-wide secret (pepper) is a valid layer, but it never replaces the salt and it complicates rotation.
  • Storing the password "temporarily" in a field, which ends up in a backup and in a log; or forgetting select: false and returning the whole user in a JSON response, the quietest way there is to leak hashes.
  • Truncating silently because of the 72-byte limit. Two long passphrases sharing a beginning would become equivalent.
  • Tip: make hashPassword/verifyPassword the only place that knows about the algorithm — migrating to Argon2 will mean changing one file — and when you raise the cost, take advantage of the login to rehash whenever the password verifies successfully: a transparent migration, with nothing asked of the user.

Exercises

Exercise 1: spot the flaws

This controller has five security flaws. Find them and explain each one.

async function register(req, res) {
  const user = await User.create(req.body);
  const hash = require('node:crypto').createHash('sha256').update(req.body.password).digest('hex');
  user.passwordHash = hash;
  await user.save();
  console.log('Registered', req.body.email, req.body.password);
  res.status(201).json(user);
}

Exercise 2: transparent rehashing

Write verifyAndUpgrade(user, plainPassword): it verifies the password and, if the password is correct but the hash uses a cost lower than configuration.bcryptCost, regenerates and saves it. Hint: the cost is inside the hash itself, between the second and third $.

Exercise 3: password recovery

List, in order, the steps of POST /auth/recover/:token, which sets a new password, stating for each one which attack it prevents.

Solutions

Exercise 1

  1. User.create(req.body): mass assignment; a body containing "role":"administrator" creates an administrator. The fields must be pulled out one by one from req.validatedData.
  2. SHA-256 as a password hash: fast and unsalted, vulnerable to GPUs and to rainbow tables. It must be bcrypt/scrypt/Argon2.
  3. console.log of the plaintext password: it stays in the logs forever.
  4. res.json(user): returns the complete document; with neither select: false nor toJSON, it exposes the hash and internal fields.
  5. No prior validation: no length policy, no e-mail normalization, no duplicate control; on top of that it answers 201, revealing whether the address already existed (enumeration). Bonus flaw: the user is created without a passwordHash and saved in two steps; if the second save() fails, an account is left with no credential.

Exercise 2. The login is the only moment when the server legitimately knows the plaintext password, so it is the only possible moment to rehash.

async function verifyAndUpgrade(user, plainPassword) {
  if (!await verifyPassword(plainPassword, user.passwordHash)) { return false; }
  // Format $2b$12$salt+hash: the cost is the third segment.
  if (Number(user.passwordHash.split('$')[2]) < configuration.bcryptCost) {
    user.passwordHash = await hashPassword(plainPassword);
    await user.save();
  }
  return true;
}

Exercise 3

Step Attack it prevents
1. Validate the body with zod Enormous inputs that trigger expensive hashing
2. consumeToken(token, 'recovery'): checks hash and expiry, and deletes it Token reuse and guessing
3. Generic error if it is invalid Distinguishing "nonexistent" from "expired" helps an attacker calibrate
4. Hash with bcrypt and save, setting verified: true Cleartext storage; whoever controls the mailbox has already proven ownership of the address
5. Revoke every session and refresh token An attacker already inside would stay inside
6. Send an e-mail warning and log the event (no token, no password) Silent account takeover; missing audit trail (08-05, 08-06)

Conclusion

Escena Viva now has a real credential. We know why a password is never stored in the clear, encrypted or hashed with SHA-256; what a password hash does — slow, salted, with an adjustable cost — and why bcrypt keeps the variant, the cost and the salt inside the hash itself. We measured the cost factor instead of copying it, completed the User model with passwordHash and select: false, written a .strict() zod schema with a modern policy, and built a POST /auth/register and a POST /auth/login that leak which e-mails exist neither through the message, nor through the code, nor through the timing. And we got right the part that most often goes wrong: single-use tokens, random, hashed in the database and with an expiry.

But look at how login ends today: we know it is Lucía… and we do nothing with that information. The next request arrives anonymous all over again. In the next lesson, Sessions and Cookies with Passport.js, we solve it the classic way: a random session identifier in an HttpOnly cookie, the state on the server, express-session with the options that really matter, the regeneration that prevents session fixation, Passport with its local strategy, and the CSRF protection that cookies demand in exchange for their convenience.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved